release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67)

This commit is contained in:
Ryan
2025-11-14 04:59:58 -05:00
committed by GitHub
parent ef47ad2b52
commit 402f590163
11 changed files with 1457 additions and 737 deletions

View File

@@ -1,5 +1,55 @@
# Changelog # 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 Resumables 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) ## Changes 11/13/2025 (v1.9.5)
release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths

View File

@@ -20,7 +20,7 @@ img.logo{ width:50px; height:50px; display:block; } /* belt & suspenders for lo
min-height: 40px; /* reserve space */ min-height: 40px; /* reserve space */
max-width: 520px; max-width: 520px;
margin: 8px auto 0; margin: 8px auto 0;
border-radius: 8px; border-radius: var(--menu-radius);
padding: 10px 12px; padding: 10px 12px;
text-align: left; text-align: left;
margin-bottom: 10px; margin-bottom: 10px;
@@ -195,7 +195,7 @@ body {
min-height: 40px; /* so the label has room */ min-height: 40px; /* so the label has room */
} }
.header-buttons button:hover { .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); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
color: #fff; color: #fff;
}@media (max-width: 600px) { }@media (max-width: 600px) {
@@ -332,12 +332,12 @@ body {
background: white; background: white;
padding: 20px; padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 4px; border-radius: var(--menu-radius);
}.dark-mode #loginForm { }.dark-mode #loginForm {
background-color: #2c2c2c; background-color: #2c2c2c;
color: #e0e0e0; color: #e0e0e0;
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: var(--menu-radius);
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.2); box-shadow: 0 2px 4px rgba(255, 255, 255, 0.2);
}.dark-mode #loginForm input { }.dark-mode #loginForm input {
background-color: #333; background-color: #333;
@@ -370,7 +370,7 @@ body {
background: #fff !important; background: #fff !important;
padding: 20px; padding: 20px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: var(--menu-radius);
}/* Override modal content for dark mode */ }/* Override modal content for dark mode */
.dark-mode #restoreFilesModal .modal-content { .dark-mode #restoreFilesModal .modal-content {
background: #2c2c2c !important; background: #2c2c2c !important;
@@ -441,7 +441,7 @@ body {
background: #fff; background: #fff;
padding: 20px; padding: 20px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: var(--menu-radius);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
max-width: 400px; max-width: 400px;
width: 90%; width: 90%;
@@ -501,7 +501,7 @@ body {
background-color: #fff; background-color: #fff;
padding: 10px 20px 20px 20px; padding: 10px 20px 20px 20px;
border: 1px solid #ccc; 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; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
z-index: 1100 !important; z-index: 1100 !important;
display: flex !important; display: flex !important;
@@ -1119,7 +1119,7 @@ body {
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
background: white; background: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 8px; border-radius: var(--menu-radius);
max-width: 100%; max-width: 100%;
padding-bottom: 10px !important; padding-bottom: 10px !important;
padding-left: 5px !important; padding-left: 5px !important;
@@ -1134,7 +1134,7 @@ body {
background-color: #2c2c2c; background-color: #2c2c2c;
color: #e0e0e0; color: #e0e0e0;
border: 1px solid #444; border: 1px solid #444;
border-radius: 8px; border-radius: var(--menu-radius);
}#fileListContainer>h2, }#fileListContainer>h2,
#fileListContainer>.file-list-actions, #fileListContainer>.file-list-actions,
#fileListContainer>#fileList { #fileListContainer>#fileList {
@@ -1393,7 +1393,7 @@ body {
max-height: 90vh; max-height: 90vh;
background: #fff; background: #fff;
padding: 20px !important; padding: 20px !important;
border-radius: 4px; border-radius: var(--menu-radius);
overflow: hidden !important; overflow: hidden !important;
margin: auto; margin: auto;
position: relative; position: relative;
@@ -1706,20 +1706,94 @@ body {
transform: translateY(-3px) !important; transform: translateY(-3px) !important;
}#restoreFilesList li label { }#restoreFilesList li label {
margin-left: 8px !important; margin-left: 8px !important;
}.dark-mode #fileContextMenu { }
background-color: #2c2c2c !important; /* ===== File context menu (CSS-only visuals) ===== */
border: 1px solid #555 !important; /* Context menu visual design */
color: #e0e0e0 !important; .filr-menu{
}.dark-mode #fileContextMenu div { position: fixed;
color: #e0e0e0 !important; z-index: 9999;
}#folderContextMenu { min-width: 220px;
font-family: Arial, sans-serif; max-width: min(320px, 90vw);
font-size: 14px; height: auto; /* don't stretch */
}.dark-mode #folderContextMenu { max-height: calc(100vh - 16px);/* never exceed viewport; adds scroll if needed */
background-color: #2c2c2c; overflow: auto;
border-color: #555; padding: 6px;
color: #e0e0e0; border-radius: 10px;
}.drop-target-sidebar { 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; display: none;
background-color: #f8f9fa; background-color: #f8f9fa;
border-right: 2px dashed #1565C0; border-right: 2px dashed #1565C0;
@@ -1798,13 +1872,35 @@ body {
box-shadow: 0 20px 30px rgba(0, 0, 0, 0.3); box-shadow: 0 20px 30px rgba(0, 0, 0, 0.3);
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
z-index: 10000; 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; transition: transform 0.3s ease, opacity 0.3s ease;
width: 100%; width: 100%;
margin-bottom: 20px; margin-bottom: 20px;
min-height: 320px; min-height: 320px;
}#uploadFolderRow.highlight {
border-radius: var(--menu-radius);
overflow: hidden; /* ensures children dont 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; min-height: 320px;
margin-bottom: 20px; margin-bottom: 20px;
}#sidebarDropArea, }#sidebarDropArea,
@@ -1981,8 +2077,11 @@ body {
color: #333; color: #333;
}.btn-icon:hover, }.btn-icon:hover,
.btn-icon:focus { .btn-icon:focus {
background: rgba(0, 0, 0, 0.1); background: var(--filr-row-hover-bg) !important;
outline: none; 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 .btn-icon .material-icons,
.dark-mode #searchIcon .material-icons { .dark-mode #searchIcon .material-icons {
color: #fff; color: #fff;
@@ -1999,7 +2098,7 @@ body {
margin-top: 0.25rem; margin-top: 0.25rem;
background: var(--bs-body-bg, #fff); background: var(--bs-body-bg, #fff);
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: var(--menu-radius);
min-width: 150px; min-width: 150px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2); box-shadow: 0 2px 6px rgba(0,0,0,0.2);
z-index: 1000; z-index: 1000;
@@ -2009,8 +2108,6 @@ body {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
}.user-dropdown .user-menu .item:hover {
background: #f5f5f5;
}.user-dropdown .dropdown-caret { }.user-dropdown .dropdown-caret {
border-top: 5px solid currentColor; border-top: 5px solid currentColor;
border-left: 5px solid transparent; border-left: 5px solid transparent;
@@ -2023,8 +2120,6 @@ body {
border-color: #444; border-color: #444;
}.dark-mode .user-dropdown .user-menu .item { }.dark-mode .user-dropdown .user-menu .item {
color: #e0e0e0; color: #e0e0e0;
}.dark-mode .user-dropdown .user-menu .item:hover {
background: rgba(255,255,255,0.1);
}.user-dropdown .dropdown-username { }.user-dropdown .dropdown-username {
margin: 0 8px; margin: 0 8px;
font-weight: 500; font-weight: 500;
@@ -2032,6 +2127,46 @@ body {
white-space: nowrap; 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 { .folder-strip-container {
display: flex; display: flex;
padding-top: 0px !important; padding-top: 0px !important;
@@ -2159,7 +2294,7 @@ body.dark-mode .folder-strip-container .folder-item:hover {
border-color: #e2e2e2; border-color: #e2e2e2;
} }
/* media modal polish */ /* media modal polish */
.media-modal { background: var(--panel-bg, #121212); } .media-modal { background: #2c2c2c; }
.media-header-bar .btn { padding: 6px 10px; } .media-header-bar .btn { padding: 6px 10px; }
.gallery-nav-btn { color: #fff; opacity: 0.85; } .gallery-nav-btn { color: #fff; opacity: 0.85; }
.gallery-nav-btn:hover { opacity: 1; transform: scale(1.05); } .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; display: inline-block;
animation: filr-spin .8s linear infinite; animation: filr-spin .8s linear infinite;
} }
@keyframes filr-spin { to { transform: rotate(360deg); } } @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 */
}

View File

@@ -477,6 +477,26 @@
</form> </form>
</div> </div>
</div> </div>
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
<div class="sep" data-when="always"></div>
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
<button type="button" class="mi" data-action="move_selected" data-when="any"><i class="material-icons">drive_file_move</i><span>Move selected</span></button>
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
<div class="sep" data-when="any"></div>
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
<button type="button" class="mi" data-action="edit" data-when="can-edit"><i class="material-icons">edit</i><span>Edit</span></button>
<button type="button" class="mi" data-action="rename" data-when="one"><i class="material-icons">drive_file_rename_outline</i><span>Rename</span></button>
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
</div>
<div id="removeUserModal" class="modal" style="display:none;"> <div id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3> <h3 data-i18n-key="remove_user_title">Remove User</h3>

View File

@@ -798,7 +798,11 @@ export async function loadFileList(folderParam) {
}) })
.map(p => ({ name: p.split("/").pop(), full: p })); .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"); let strip = document.getElementById("folderStripContainer");
if (!strip) { if (!strip) {
@@ -958,7 +962,7 @@ export function renderFileTable(folder, container, subfolders) {
} }
return `<table class="filr-table"${attrs}>`; return `<table class="filr-table"${attrs}>`;
}); });
const startIndex = (currentPage - 1) * itemsPerPageSetting; const startIndex = (currentPage - 1) * itemsPerPageSetting;
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles); const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
let rowsHTML = "<tbody>"; let rowsHTML = "<tbody>";

View File

@@ -1,154 +1,246 @@
// fileMenu.js // fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.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 { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}'; import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}'; import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.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) { const MENU_ID = 'fileContextMenu';
let menu = document.getElementById("fileContextMenu");
if (!menu) { function qMenu() { return document.getElementById(MENU_ID); }
menu = document.createElement("div"); function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
menu.id = "fileContextMenu";
menu.style.position = "fixed"; // One-time: localize labels
menu.style.backgroundColor = "#fff"; function localizeMenu() {
menu.style.border = "1px solid #ccc"; const m = qMenu(); if (!m) return;
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)"; const map = {
menu.style.zIndex = "9999"; 'create_file': 'create_file',
menu.style.padding = "5px 0"; 'delete_selected': 'delete_selected',
menu.style.minWidth = "150px"; 'copy_selected': 'copy_selected',
document.body.appendChild(menu); 'move_selected': 'move_selected',
} 'download_zip': 'download_zip',
menu.innerHTML = ""; 'extract_zip': 'extract_zip',
menuItems.forEach(item => { 'tag_selected': 'tag_selected',
let menuItem = document.createElement("div"); 'preview': 'preview',
menuItem.textContent = item.label; 'edit': 'edit',
menuItem.style.padding = "5px 15px"; 'rename': 'rename',
menuItem.style.cursor = "pointer"; 'tag_file': 'tag_file'
menuItem.addEventListener("mouseover", () => { };
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0"; Object.entries(map).forEach(([action, key]) => {
}); const el = m.querySelector(`.mi[data-action="${action}"]`);
menuItem.addEventListener("mouseout", () => { if (el) setText(el, key);
menuItem.style.backgroundColor = "";
});
menuItem.addEventListener("click", () => {
item.action();
hideFileContextMenu();
});
menu.appendChild(menuItem);
}); });
}
menu.style.left = x + "px"; // Show/hide items based on selection state
menu.style.top = y + "px"; function configureVisibility({ any, one, many, anyZip, canEdit }) {
menu.style.display = "block"; const m = qMenu(); if (!m) return;
const menuRect = menu.getBoundingClientRect(); const show = (sel, on) => sel.forEach(el => el.hidden = !on);
const viewportHeight = window.innerHeight;
if (menuRect.bottom > viewportHeight) { show(m.querySelectorAll('[data-when="always"]'), true);
let newTop = viewportHeight - menuRect.height; show(m.querySelectorAll('[data-when="any"]'), any);
if (newTop < 0) newTop = 0; show(m.querySelectorAll('[data-when="one"]'), one);
menu.style.top = newTop + "px"; 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() { export function hideFileContextMenu() {
const menu = document.getElementById("fileContextMenu"); const m = qMenu();
if (menu) { if (m) m.hidden = true;
menu.style.display = "none"; }
}
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) { export function fileListContextMenuHandler(e) {
e.preventDefault(); e.preventDefault();
let row = e.target.closest("tr"); // Check row if needed
const row = e.target.closest('tr');
if (row) { if (row) {
const checkbox = row.querySelector(".file-checkbox"); const cb = row.querySelector('.file-checkbox');
if (checkbox && !checkbox.checked) { if (cb && !cb.checked) {
checkbox.checked = true; cb.checked = true;
updateRowHighlight(checkbox); 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 = [ // Stash for click handlers
{ label: t("create_file"), action: () => openCreateFileModal() }, window.__filr_ctx_state = state;
{ 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);
} }
// --- 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 cant 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() { export function bindFileListContextMenu() {
const fileListContainer = document.getElementById("fileList"); const container = document.getElementById('fileList');
if (fileListContainer) { const menu = qMenu();
fileListContainer.oncontextmenu = fileListContextMenuHandler; 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) { // Rebind after table render (keeps your original behavior)
const menu = document.getElementById("fileContextMenu");
if (menu && menu.style.display === "block") {
hideFileContextMenu();
}
});
// Rebind context menu after file table render.
(function () { (function () {
const originalRenderFileTable = window.renderFileTable; const orig = window.renderFileTable;
window.renderFileTable = function (folder) { if (typeof orig === 'function') {
originalRenderFileTable(folder); window.renderFileTable = function (folder) {
bindFileListContextMenu(); orig(folder);
}; bindFileListContextMenu();
};
} else {
// If not present yet, bind once DOM is ready
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
}
})(); })();

View File

@@ -160,7 +160,7 @@ function ensureMediaModal() {
const root = document.documentElement; const root = document.documentElement;
const styles = getComputedStyle(root); const styles = getComputedStyle(root);
const isDark = root.classList.contains('dark-mode'); 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 textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)'; const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';

View File

@@ -1,172 +1,214 @@
// fileTags.js // fileTags.js (drop-in fix: single-instance modals, idempotent bindings)
// 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.
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}'; import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}';
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}'; import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
export function openTagModal(file) { // -------------------- state --------------------
// Create the modal element. let __singleInit = false;
let modal = document.createElement('div'); let __multiInit = false;
modal.id = 'tagModal'; let currentFile = null;
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="
margin:0;
display:inline-block;
max-width: calc(100% - 40px);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
">
${t("tag_file")}: ${escapeHTML(file.name)}
</h3>
<span id="closeTagModal" class="editor-close-btn">&times;</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">${t("tag_name")}</label>
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
<br><br>
<label for="tagColorInput">${t("tag_name")}</label>
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
<!-- Custom tag options will be populated here -->
</div>
<br>
<div style="text-align:right;">
<button id="saveTagBtn" class="btn btn-primary">${t("save_tag")}</button>
</div>
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
<!-- Existing tags will be listed here -->
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
updateCustomTagDropdown(); // Global store (preserve existing behavior)
window.globalTags = window.globalTags || [];
document.getElementById('closeTagModal').addEventListener('click', () => { if (localStorage.getItem('globalTags')) {
modal.remove(); try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {}
});
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();
});
} }
/** // -------------------- ensure DOM (create-once-if-missing) --------------------
* Open a modal to tag multiple files. function ensureSingleTagModal() {
* @param {Array} files - Array of file objects to tag. // de-dupe if something already injected multiples
*/ const all = document.querySelectorAll('#tagModal');
export function openMultiTagModal(files) { if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
let modal = document.createElement('div');
modal.id = 'multiTagModal'; let modal = document.getElementById('tagModal');
modal.className = 'modal'; if (!modal) {
modal.innerHTML = ` document.body.insertAdjacentHTML('beforeend', `
<div class="modal-content" style="width: 450px; max-width:90vw;"> <div id="tagModal" class="modal" style="display:none">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="modal-content" style="width:450px; max-width:90vw;">
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3> <div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<span id="closeMultiTagModal" class="editor-close-btn">&times;</span> <h3 id="tagModalTitle" style="margin:0; max-width:calc(100% - 40px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
</div> ${t('tag_file')}
<div class="modal-body" style="margin-top:10px;"> </h3>
<label for="multiTagNameInput">Tag Name:</label> <span id="closeTagModal" class="editor-close-btn">×</span>
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/> </div>
<br><br> <div class="modal-body" style="margin-top:10px;">
<label for="multiTagColorInput">Tag Color:</label> <label for="tagNameInput">${t('tag_name')}</label>
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/> <input type="text" id="tagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
<br><br> <br><br>
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"> <label for="tagColorInput">${t('tag_color') || 'Tag Color'}</label>
<!-- Custom tag options will be populated here --> <input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
</div> <br><br>
<br> <div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
<div style="text-align:right;"> <br>
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button> <div style="text-align:right;">
<button id="saveTagBtn" class="btn btn-primary" type="button">${t('save_tag')}</button>
</div>
<div id="currentTags" style="margin-top:10px; font-size:.9em;"></div>
</div>
</div> </div>
</div> </div>
</div> `);
`; modal = document.getElementById('tagModal');
document.body.appendChild(modal); }
modal.style.display = 'block'; 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', () => { let modal = document.getElementById('multiTagModal');
modal.remove(); if (!modal) {
document.body.insertAdjacentHTML('beforeend', `
<div id="multiTagModal" class="modal" style="display:none">
<div class="modal-content" style="width:450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 id="multiTagTitle" style="margin:0;"></h3>
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">${t('tag_name')}</label>
<input type="text" id="multiTagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
<br><br>
<label for="multiTagColorInput">${t('tag_color') || 'Tag Color'}</label>
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
<br>
<div style="text-align:right;">
<button id="saveMultiTagBtn" class="btn btn-primary" type="button">${t('save_tag') || 'Save Tag'}</button>
</div>
</div>
</div>
</div>
`);
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) => { // Input filter for dropdown
updateMultiCustomTagDropdown(e.target.value); 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', () => { __singleInit = true;
const tagName = document.getElementById('multiTagNameInput').value.trim(); }
const tagColor = document.getElementById('multiTagColorInput').value;
if (!tagName) { function initMultiModalOnce() {
alert('Please enter a tag name.'); if (__multiInit) return;
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 => { files.forEach(file => {
addTagToFile(file, { name: tagName, color: tagColor }); addTagToFile(file, { name: tagName, color: tagColor });
updateFileRowTagDisplay(file); updateFileRowTagDisplay(file);
saveFileTags(file); saveFileTags(file);
}); });
modal.remove();
if (window.viewMode === 'gallery') { hideMultiTagModal();
renderGalleryView(window.currentFolder); if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
} else { else renderFileTable(window.currentFolder);
renderFileTable(window.currentFolder);
}
}); });
__multiInit = true;
} }
/** // -------------------- open/close APIs --------------------
* Update the custom dropdown for multi-tag modal. export function openTagModal(file) {
* Similar to updateCustomTagDropdown but includes a remove icon. 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 = "") { function updateMultiCustomTagDropdown(filterText = "") {
const dropdown = document.getElementById("multiCustomTagDropdown"); const dropdown = document.getElementById("multiCustomTagDropdown");
if (!dropdown) return; if (!dropdown) return;
dropdown.innerHTML = ""; dropdown.innerHTML = "";
let tags = window.globalTags || []; let tags = window.globalTags || [];
if (filterText) { if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (tags.length > 0) { if (tags.length > 0) {
tags.forEach(tag => { tags.forEach(tag => {
const item = document.createElement("div"); const item = document.createElement("div");
item.style.cursor = "pointer"; item.style.cursor = "pointer";
item.style.padding = "5px"; item.style.padding = "5px";
item.style.borderBottom = "1px solid #eee"; item.style.borderBottom = "1px solid #eee";
// Display colored square and tag name with remove icon.
item.innerHTML = ` item.innerHTML = `
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span> <span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
${escapeHTML(tag.name)} ${escapeHTML(tag.name)}
@@ -174,8 +216,10 @@ function updateMultiCustomTagDropdown(filterText = "") {
`; `;
item.addEventListener("click", function(e) { item.addEventListener("click", function(e) {
if (e.target.classList.contains("global-remove")) return; if (e.target.classList.contains("global-remove")) return;
document.getElementById("multiTagNameInput").value = tag.name; const n = document.getElementById("multiTagNameInput");
document.getElementById("multiTagColorInput").value = tag.color; 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){ item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation(); e.stopPropagation();
@@ -184,7 +228,7 @@ function updateMultiCustomTagDropdown(filterText = "") {
dropdown.appendChild(item); dropdown.appendChild(item);
}); });
} else { } else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>"; dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
} }
} }
@@ -193,9 +237,7 @@ function updateCustomTagDropdown(filterText = "") {
if (!dropdown) return; if (!dropdown) return;
dropdown.innerHTML = ""; dropdown.innerHTML = "";
let tags = window.globalTags || []; let tags = window.globalTags || [];
if (filterText) { if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (tags.length > 0) { if (tags.length > 0) {
tags.forEach(tag => { tags.forEach(tag => {
const item = document.createElement("div"); const item = document.createElement("div");
@@ -209,8 +251,10 @@ function updateCustomTagDropdown(filterText = "") {
`; `;
item.addEventListener("click", function(e){ item.addEventListener("click", function(e){
if (e.target.classList.contains('global-remove')) return; if (e.target.classList.contains('global-remove')) return;
document.getElementById("tagNameInput").value = tag.name; const n = document.getElementById("tagNameInput");
document.getElementById("tagColorInput").value = tag.color; 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){ item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation(); e.stopPropagation();
@@ -219,16 +263,16 @@ function updateCustomTagDropdown(filterText = "") {
dropdown.appendChild(item); dropdown.appendChild(item);
}); });
} else { } else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>"; dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
} }
} }
// Update the modal display to show current tags on the file. // Update the modal display to show current tags on the file.
function updateTagModalDisplay(file) { function updateTagModalDisplay(file) {
const container = document.getElementById('currentTags'); const container = document.getElementById('currentTags');
if (!container) return; if (!container) return;
container.innerHTML = '<strong>Current Tags:</strong> '; container.innerHTML = `<strong>${t('current_tags') || 'Current Tags'}:</strong> `;
if (file.tags && file.tags.length > 0) { if (file?.tags?.length) {
file.tags.forEach(tag => { file.tags.forEach(tag => {
const tagElem = document.createElement('span'); const tagElem = document.createElement('span');
tagElem.textContent = tag.name; tagElem.textContent = tag.name;
@@ -239,102 +283,65 @@ function updateTagModalDisplay(file) {
tagElem.style.borderRadius = '3px'; tagElem.style.borderRadius = '3px';
tagElem.style.display = 'inline-block'; tagElem.style.display = 'inline-block';
tagElem.style.position = 'relative'; tagElem.style.position = 'relative';
const removeIcon = document.createElement('span'); const removeIcon = document.createElement('span');
removeIcon.textContent = ' ✕'; removeIcon.textContent = ' ✕';
removeIcon.style.fontWeight = 'bold'; removeIcon.style.fontWeight = 'bold';
removeIcon.style.marginLeft = '3px'; removeIcon.style.marginLeft = '3px';
removeIcon.style.cursor = 'pointer'; removeIcon.style.cursor = 'pointer';
removeIcon.addEventListener('click', (e) => { removeIcon.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
removeTagFromFile(file, tag.name); removeTagFromFile(file, tag.name);
}); });
tagElem.appendChild(removeIcon); tagElem.appendChild(removeIcon);
container.appendChild(tagElem); container.appendChild(tagElem);
}); });
} else { } else {
container.innerHTML += 'None'; container.innerHTML += (t('none') || 'None');
} }
} }
function removeTagFromFile(file, tagName) { 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); updateTagModalDisplay(file);
updateFileRowTagDisplay(file); updateFileRowTagDisplay(file);
saveFileTags(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) { 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)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown(); updateCustomTagDropdown();
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
saveGlobalTagRemoval(tagName); saveGlobalTagRemoval(tagName);
} }
// NEW: Save global tag removal to the server.
function saveGlobalTagRemoval(tagName) { function saveGlobalTagRemoval(tagName) {
fetch("/api/file/saveFileTag.php", { fetch("/api/file/saveFileTag.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
"Content-Type": "application/json", body: JSON.stringify({ folder: "root", file: "global", deleteGlobal: true, tagToDelete: tagName, tags: [] })
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder: "root",
file: "global",
deleteGlobal: true,
tagToDelete: tagName,
tags: []
})
}) })
.then(response => response.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success && data.globalTags) {
console.log("Global tag removed:", tagName); window.globalTags = data.globalTags;
if (data.globalTags) { localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
window.globalTags = data.globalTags; updateCustomTagDropdown();
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); updateMultiCustomTagDropdown();
updateCustomTagDropdown(); } else if (!data.success) {
updateMultiCustomTagDropdown(); console.error("Error removing global tag:", data.error);
} }
} else { })
console.error("Error removing global tag:", data.error); .catch(err => console.error("Error removing global tag:", err));
}
})
.catch(err => {
console.error("Error removing global tag:", err);
});
} }
// Global store for reusable tags. // -------------------- exports kept from your original --------------------
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.
export function loadGlobalTags() { export function loadGlobalTags() {
fetch("/api/file/getFileTag.php", { credentials: "include" }) fetch("/api/file/getFileTag.php", { credentials: "include" })
.then(response => { .then(r => r.ok ? r.json() : [])
if (!response.ok) {
// If the file doesn't exist, assume there are no global tags.
return [];
}
return response.json();
})
.then(data => { .then(data => {
window.globalTags = data; window.globalTags = data || [];
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown(); updateCustomTagDropdown();
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
@@ -346,142 +353,113 @@ export function loadGlobalTags() {
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
}); });
} }
loadGlobalTags(); loadGlobalTags();
// Add (or update) a tag in the file object.
export function addTagToFile(file, tag) { export function addTagToFile(file, tag) {
if (!file.tags) { if (!file.tags) 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 exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (exists) { const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
exists.color = tag.color;
} else {
file.tags.push(tag);
}
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (!globalExists) { if (!globalExists) {
window.globalTags.push(tag); window.globalTags.push(tag);
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
} }
} }
// Update the file row (in table view) to show tag badges.
export function updateFileRowTagDisplay(file) { export function updateFileRowTagDisplay(file) {
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`); const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
console.log('Updating tags for rows:', rows);
rows.forEach(row => { rows.forEach(row => {
let cell = row.querySelector('.file-name-cell'); let cell = row.querySelector('.file-name-cell');
if (cell) { if (!cell) return;
let badgeContainer = cell.querySelector('.tag-badges'); let badgeContainer = cell.querySelector('.tag-badges');
if (!badgeContainer) { if (!badgeContainer) {
badgeContainer = document.createElement('div'); badgeContainer = document.createElement('div');
badgeContainer.className = 'tag-badges'; badgeContainer.className = 'tag-badges';
badgeContainer.style.display = 'inline-block'; badgeContainer.style.display = 'inline-block';
badgeContainer.style.marginLeft = '5px'; badgeContainer.style.marginLeft = '5px';
cell.appendChild(badgeContainer); 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);
});
}
} }
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() { export function initTagSearch() {
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
if (searchInput) { if (!searchInput) return;
let tagSearchInput = document.getElementById('tagSearchInput'); let tagSearchInput = document.getElementById('tagSearchInput');
if (!tagSearchInput) { if (!tagSearchInput) {
tagSearchInput = document.createElement('input'); tagSearchInput = document.createElement('input');
tagSearchInput.id = 'tagSearchInput'; tagSearchInput.id = 'tagSearchInput';
tagSearchInput.placeholder = 'Filter by tag'; tagSearchInput.placeholder = t('filter_by_tag') || 'Filter by tag';
tagSearchInput.style.marginLeft = '10px'; tagSearchInput.style.marginLeft = '10px';
tagSearchInput.style.padding = '5px'; tagSearchInput.style.padding = '5px';
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling); searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
tagSearchInput.addEventListener('input', () => { tagSearchInput.addEventListener('input', () => {
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase(); window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
if (window.currentFolder) { if (window.currentFolder) renderFileTable(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;
}); });
} }
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() { function updateGlobalTagList() {
const dataList = document.getElementById("globalTagList"); const dataList = document.getElementById("globalTagList");
if (dataList) { if (!dataList) return;
dataList.innerHTML = ""; dataList.innerHTML = "";
window.globalTags.forEach(tag => { (window.globalTags || []).forEach(tag => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = tag.name; option.value = tag.name;
dataList.appendChild(option); dataList.appendChild(option);
}); });
}
} }
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) { export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
const folder = file.folder || "root"; const folder = file.folder || "root";
const payload = { const payload = deleteGlobal && tagToDelete ? {
folder: folder, folder: "root",
file: file.name, file: "global",
tags: file.tags deleteGlobal: true,
}; tagToDelete,
if (deleteGlobal && tagToDelete) { tags: []
payload.file = "global"; } : { folder, file: file.name, tags: file.tags };
payload.deleteGlobal = true;
payload.tagToDelete = tagToDelete;
}
fetch("/api/file/saveFileTag.php", { fetch("/api/file/saveFileTag.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify(payload) body: JSON.stringify(payload)
}) })
.then(response => response.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
console.log("Tags saved:", data);
if (data.globalTags) { if (data.globalTags) {
window.globalTags = data.globalTags; window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags)); localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown(); updateCustomTagDropdown();
updateMultiCustomTagDropdown(); updateMultiCustomTagDropdown();
} }
updateGlobalTagList();
} else { } else {
console.error("Error saving tags:", data.error); console.error("Error saving tags:", data.error);
} }
}) })
.catch(err => { .catch(err => console.error("Error saving tags:", err));
console.error("Error saving tags:", err);
});
} }

View File

@@ -194,7 +194,13 @@ async function findFirstAccessibleFolder(startFolder = 'root') {
const name = (typeof it === 'string') ? it : (it && it.name); const name = (typeof it === 'string') ? it : (it && it.name);
if (!name) continue; if (!name) continue;
const lower = String(name).toLowerCase(); 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}`; const child = (f === 'root') ? name : `${f}/${name}`;
if (!visited.has(child)) q.push(child); if (!visited.has(child)) q.push(child);
} }
@@ -728,13 +734,17 @@ async function fetchChildrenOnce(folder) {
const body = await safeJson(res); const body = await safeJson(res);
const raw = Array.isArray(body.items) ? body.items : []; const raw = Array.isArray(body.items) ? body.items : [];
const items = raw const items = raw
.map(normalizeItem) .map(normalizeItem)
.filter(Boolean) .filter(Boolean)
.filter(it => { .filter(it => {
const s = it.name.toLowerCase(); const s = it.name.toLowerCase();
return s !== 'trash' && s !== 'profile_pics'; return (
}); s !== 'trash' &&
s !== 'profile_pics' &&
!s.startsWith('resumable_')
);
});
const payload = { items, nextCursor: body.nextCursor ?? null }; const payload = { items, nextCursor: body.nextCursor ?? null };
_childCache.set(folder, payload); _childCache.set(folder, payload);
@@ -757,7 +767,8 @@ async function loadMoreChildren(folder, ulEl, moreLi) {
.filter(Boolean) .filter(Boolean)
.filter(it => { .filter(it => {
const s = it.name.toLowerCase(); 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; const nextCursor = body.nextCursor ?? null;
@@ -1503,15 +1514,107 @@ selectFolder(target);
export function loadFolderList(selectedFolder) { loadFolderTree(selectedFolder); } // compat 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: <button.mi><i.material-icons/><span/>)
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) { 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; if (!target) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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')) { if (target.classList && target.classList.contains('locked')) {
const folder = target.getAttribute('data-folder') || ''; const folder = target.getAttribute('data-folder') || '';
const ul = getULForFolder(folder); const ul = getULForFolder(folder);
@@ -1527,89 +1630,59 @@ async function folderManagerContextMenuHandler(e) {
return; return;
} }
const folder = target.getAttribute("data-folder"); const folder = target.getAttribute('data-folder');
if (!folder) return; if (!folder) return;
window.currentFolder = folder; window.currentFolder = folder;
await applyFolderCapabilities(folder); await applyFolderCapabilities(folder);
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected")); document.querySelectorAll('.folder-option, .breadcrumb-link').forEach(el => el.classList.remove('selected'));
target.classList.add("selected"); target.classList.add('selected');
const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit); const canColor = !!(window.currentFolderCaps && window.currentFolderCaps.canEdit);
const menuItems = [ const menuItems = [
{ label: t("create_folder"), action: () => { { label: t('create_folder'), action: () => {
const modal = document.getElementById("createFolderModal"); const modal = document.getElementById('createFolderModal');
const input = document.getElementById("newFolderName"); const input = document.getElementById('newFolderName');
if (modal) modal.style.display = "block"; if (modal) modal.style.display = 'block';
if (input) input.focus(); if (input) input.focus();
} }, }},
{ label: t("move_folder"), action: () => openMoveFolderUI(folder) }, { label: t('move_folder'), action: () => openMoveFolderUI(folder) },
{ label: t("rename_folder"), action: () => openRenameFolderModal() }, { label: t('rename_folder'), action: () => openRenameFolderModal() },
...(canColor ? [{ label: t("color_folder"), action: () => openColorFolderModal(folder) }] : []), ...(canColor ? [{ label: t('color_folder'), action: () => openColorFolderModal(folder) }] : []),
{ label: t("folder_share"), action: () => openFolderShareModal(folder) }, { label: t('folder_share'), action: () => openFolderShareModal(folder) },
{ label: t("delete_folder"), action: () => openDeleteFolderModal() } { label: t('delete_folder'), action: () => openDeleteFolderModal() },
]; ];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
} showFolderManagerContextMenu(e.clientX, e.clientY, 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";
} }
function bindFolderManagerContextMenu() { function bindFolderManagerContextMenu() {
const tree = document.getElementById("folderTreeContainer"); const tree = document.getElementById('folderTreeContainer');
if (tree) { if (tree) {
if (tree._ctxHandler) tree.removeEventListener("contextmenu", tree._ctxHandler, false); if (tree._ctxHandler) tree.removeEventListener('contextmenu', tree._ctxHandler, false);
tree._ctxHandler = (e) => { tree._ctxHandler = (e) => {
const onOption = e.target.closest(".folder-option"); const onOption = e.target.closest('.folder-option');
if (!onOption) return; if (!onOption) return;
folderManagerContextMenuHandler(e); 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) {
if (title._ctxHandler) title.removeEventListener("contextmenu", title._ctxHandler, false); if (title._ctxHandler) title.removeEventListener('contextmenu', title._ctxHandler, false);
title._ctxHandler = (e) => { title._ctxHandler = (e) => {
const onCrumb = e.target.closest(".breadcrumb-link"); const onCrumb = e.target.closest('.breadcrumb-link');
if (!onCrumb) return; if (!onCrumb) return;
folderManagerContextMenuHandler(e); 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 Rename / Delete / Create hooks

View File

@@ -6,6 +6,176 @@ import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}'; import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.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 = `
<div class="upload-resume-banner-inner">
<span class="material-icons" style="vertical-align:middle;margin-right:6px;">cloud_upload</span>
<span class="upload-resume-text">
${countText}
<strong>${escapeHTML(latest.fileName)}</strong>
(~${latest.lastPercent}%).
Choose it again from your device to resume.
</span>
<button type="button" class="upload-resume-dismiss-btn">Dismiss</button>
</div>
`;
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 DragandDrop Folder Uploads (Original Code) Helpers for DragandDrop Folder Uploads (Original Code)
----------------------------------------------------- */ ----------------------------------------------------- */
@@ -456,7 +626,7 @@ async function initResumableUpload() {
chunkSize: 1.5 * 1024 * 1024, chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3, simultaneousUploads: 3,
forceChunkSize: true, forceChunkSize: true,
testChunks: false, testChunks: true,
withCredentials: true, withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken }, headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({ query: () => ({
@@ -493,6 +663,11 @@ async function initResumableUpload() {
window.selectedFiles = []; window.selectedFiles = [];
} }
window.selectedFiles.push(file); window.selectedFiles.push(file);
// Track as in-progress draft at 0%
upsertResumableDraft(file, 0);
showResumableDraftBanner();
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
// Check if a wrapper already exists; if not, create one with a UL inside. // 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) { resumableInstance.on("fileProgress", function (file) {
const progress = file.progress(); // value between 0 and 1 const progress = file.progress(); // value between 0 and 1
const percent = Math.floor(progress * 100); let percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
// 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 =
'<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
}
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.disabled = false;
}
}
if (li && li.progressBar) { if (li && li.progressBar) {
if (percent < 99) { if (percent < 99) {
li.progressBar.style.width = percent + "%"; li.progressBar.style.width = percent + "%";
@@ -553,6 +760,7 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false; pauseResumeBtn.disabled = false;
} }
} }
upsertResumableDraft(file, percent);
}); });
resumableInstance.on("fileSuccess", function (file, message) { resumableInstance.on("fileSuccess", function (file, message) {
@@ -591,6 +799,9 @@ async function initResumableUpload() {
} }
refreshFolderIcon(window.currentFolder); refreshFolderIcon(window.currentFolder);
loadFileList(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; pauseResumeBtn.disabled = false;
} }
showToast("Error uploading file: " + file.fileName); 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. // 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) { if (!hasError) {
// All files succeeded—clear the file input and progress container after 5 seconds. // All files succeeded—clear the file input and progress container after 5 seconds.
setTimeout(() => { setTimeout(() => {
const fileInput = document.getElementById("file"); const fileInput = document.getElementById("file");
if (fileInput) fileInput.value = ""; if (fileInput) fileInput.value = "";
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
progressContainer.innerHTML = ""; if (progressContainer) {
progressContainer.innerHTML = "";
}
window.selectedFiles = []; window.selectedFiles = [];
adjustFolderHelpExpansionClosed(); adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fileInfoContainer = document.getElementById("fileInfoContainer");
@@ -628,6 +843,15 @@ async function initResumableUpload() {
} }
const dropArea = document.getElementById("uploadDropArea"); const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault(); 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); }, 5000);
} else { } else {
showToast("Some files failed to upload. Please check the list."); showToast("Some files failed to upload. Please check the list.");
@@ -651,11 +875,34 @@ function submitFiles(allFiles) {
const f = window.currentFolder || "root"; const f = window.currentFolder || "root";
try { return decodeURIComponent(f); } catch { return f; } try { return decodeURIComponent(f); } catch { return f; }
})(); })();
const progressContainer = document.getElementById("uploadProgressContainer"); const progressContainer = document.getElementById("uploadProgressContainer");
const fileInput = document.getElementById("file"); 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 progressElements = {};
const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
listItems.forEach(item => { listItems.forEach(item => {
progressElements[item.dataset.uploadIndex] = item; progressElements[item.dataset.uploadIndex] = item;
}); });
@@ -681,7 +928,7 @@ function submitFiles(allFiles) {
if (e.lengthComputable) { if (e.lengthComputable) {
currentPercent = Math.round((e.loaded / e.total) * 100); currentPercent = Math.round((e.loaded / e.total) * 100);
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
const elapsed = (Date.now() - li.startTime) / 1000; const elapsed = (Date.now() - li.startTime) / 1000;
let speed = ""; let speed = "";
if (elapsed > 0) { if (elapsed > 0) {
@@ -717,12 +964,12 @@ function submitFiles(allFiles) {
return; // skip the "finishedCount++" and error/success logic for now return; // skip the "finishedCount++" and error/success logic for now
} }
// ─── Normal success/error handling ──────────────────────────── // ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) { if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success // real success
if (li) { if (li && li.progressBar) {
li.progressBar.style.width = "100%"; li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done"; li.progressBar.innerText = "Done";
if (li.removeBtn) li.removeBtn.style.display = "none"; if (li.removeBtn) li.removeBtn.style.display = "none";
@@ -731,39 +978,40 @@ function submitFiles(allFiles) {
} else { } else {
// real failure // real failure
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
allSucceeded = false; allSucceeded = false;
} }
if (file.isClipboard) { if (file.isClipboard) {
setTimeout(() => { setTimeout(() => {
window.selectedFiles = []; window.selectedFiles = [];
updateFileInfoCount(); updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer"); const pc = document.getElementById("uploadProgressContainer");
if (progressContainer) progressContainer.innerHTML = ""; if (pc) pc.innerHTML = "";
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fic = document.getElementById("fileInfoContainer");
if (fileInfoContainer) { if (fic) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`; fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
} }
}, 5000); }, 5000);
} }
// ─── Only now count this chunk as finished ─────────────────── // ─── Only now count this upload as finished ───────────────────
finishedCount++; finishedCount++;
if (finishedCount === allFiles.length) { if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length; const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount; const failedCount = allFiles.length - succeededCount;
setTimeout(() => { setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements); refreshFileList(allFiles, uploadResults, progressElements);
}, 250); }, 250);
} }
}); });
xhr.addEventListener("error", function () { xhr.addEventListener("error", function () {
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Error"; li.progressBar.innerText = "Error";
} }
uploadResults[file.uploadIndex] = false; uploadResults[file.uploadIndex] = false;
@@ -779,7 +1027,7 @@ if (finishedCount === allFiles.length) {
xhr.addEventListener("abort", function () { xhr.addEventListener("abort", function () {
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
if (li) { if (li && li.progressBar) {
li.progressBar.innerText = "Aborted"; li.progressBar.innerText = "Aborted";
} }
uploadResults[file.uploadIndex] = false; uploadResults[file.uploadIndex] = false;
@@ -809,38 +1057,42 @@ if (finishedCount === allFiles.length) {
}) })
.map(s => s.trim().toLowerCase()) .map(s => s.trim().toLowerCase())
.filter(Boolean); .filter(Boolean);
let overallSuccess = true; let overallSuccess = true;
let succeeded = 0; let succeeded = 0;
allFiles.forEach(file => { allFiles.forEach(file => {
const clientFileName = file.name.trim().toLowerCase(); const clientFileName = file.name.trim().toLowerCase();
const li = progressElements[file.uploadIndex]; const li = progressElements[file.uploadIndex];
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath); 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"; li.progressBar.innerText = "Error";
} }
overallSuccess = false; overallSuccess = false;
} else if (li) { } else if (li) {
succeeded++; succeeded++;
// Schedule removal of successful file entry after 5 seconds. // Schedule removal of successful file entry after 5 seconds.
setTimeout(() => { setTimeout(() => {
li.remove(); li.remove();
delete progressElements[file.uploadIndex]; delete progressElements[file.uploadIndex];
updateFileInfoCount(); updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer"); const pc = document.getElementById("uploadProgressContainer");
if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) { if (pc && pc.querySelectorAll("li.upload-progress-item").length === 0) {
const fileInput = document.getElementById("file"); const fi = document.getElementById("file");
if (fileInput) fileInput.value = ""; if (fi) fi.value = "";
progressContainer.innerHTML = ""; pc.innerHTML = "";
adjustFolderHelpExpansionClosed(); adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer"); const fic = document.getElementById("fileInfoContainer");
if (fileInfoContainer) { if (fic) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`; fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
} }
const dropArea = document.getElementById("uploadDropArea"); const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault(); if (dropArea) setDropAreaDefault();
window.selectedFiles = [];
} }
}, 5000); }, 5000);
} }
@@ -850,7 +1102,7 @@ if (finishedCount === allFiles.length) {
const failed = allFiles.length - succeeded; const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`); showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else { } else {
showToast(`${succeeded} file succeeded. Please check the list.`); showToast(`${succeeded} file(s) succeeded. Please check the list.`);
} }
}) })
.catch(error => { .catch(error => {
@@ -859,7 +1111,6 @@ if (finishedCount === allFiles.length) {
}) })
.finally(() => { .finally(() => {
loadFolderTree(window.currentFolder); loadFolderTree(window.currentFolder);
}); });
} }
} }
@@ -918,17 +1169,23 @@ function initUpload() {
fileInput.addEventListener("change", async function () { fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []); const files = Array.from(fileInput.files || []);
if (!files.length) return; if (!files.length) return;
if (useResumable) { if (useResumable) {
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
// Ensure the lib/instance exists // Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload(); if (!_resumableReady) await initResumableUpload();
if (resumableInstance) { if (resumableInstance) {
for (const f of files) resumableInstance.addFile(f); for (const f of files) {
resumableInstance.addFile(f);
}
} else { } else {
// If still not ready (load error), fall back to your XHR path // If Resumable failed to load, fall back to XHR
processFiles(files); processFiles(files);
} }
} else { } else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files); processFiles(files);
} }
}); });
@@ -937,27 +1194,40 @@ function initUpload() {
if (uploadForm) { if (uploadForm) {
uploadForm.addEventListener("submit", async function (e) { uploadForm.addEventListener("submit", async function (e) {
e.preventDefault(); 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) { if (!files || !files.length) {
showToast("No files selected."); showToast("No files selected.");
return; return;
} }
// Resumable path (only for picked files, not folder uploads) // If we have any files queued in Resumable, treat this as a resumable upload.
const first = files[0]; const hasResumableFiles =
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath); useResumable &&
if (useResumable && !isFolderish) { resumableInstance &&
Array.isArray(resumableInstance.files) &&
resumableInstance.files.length > 0;
if (hasResumableFiles) {
if (!_resumableReady) await initResumableUpload(); if (!_resumableReady) await initResumableUpload();
if (resumableInstance) { if (resumableInstance) {
// ensure folder/token fresh // Keep folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root"; resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.opts.query.upload_token = window.csrfToken;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
resumableInstance.upload(); resumableInstance.upload();
showToast("Resumable upload started..."); showToast("Resumable upload started...");
} else { } else {
// fallback // Hard fallback should basically never happen
submitFiles(files); submitFiles(files);
} }
} else { } else {
// No resumable queue → drag-and-drop / paste / simple input → XHR path
submitFiles(files); submitFiles(files);
} }
}); });
@@ -966,6 +1236,7 @@ function initUpload() {
if (useResumable) { if (useResumable) {
initResumableUpload(); initResumableUpload();
} }
showResumableDraftBanner();
} }
export { initUpload }; export { initUpload };

View File

@@ -5,116 +5,143 @@ require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php'; require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php'; require_once PROJECT_ROOT . '/src/models/UploadModel.php';
class UploadController { class UploadController
{
public function handleUpload(): void { public function handleUpload(): void
{
header('Content-Type: application/json'); header('Content-Type: application/json');
// ---- 1) CSRF (header or form field) ---- $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER); $requestParams = ($method === 'GET') ? $_GET : $_POST;
$received = '';
if (!empty($headersArr['x-csrf-token'])) { // Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
$received = trim($headersArr['x-csrf-token']); $isResumableTest =
} elseif (!empty($_POST['csrf_token'])) { ($method === 'GET'
$received = trim($_POST['csrf_token']); && isset($requestParams['resumableChunkNumber'])
} elseif (!empty($_POST['upload_token'])) { && isset($requestParams['resumableIdentifier']));
// legacy alias
$received = trim($_POST['upload_token']); // ---- 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 ---- // ---- 2) Auth + account-level flags ----
if (empty($_SESSION['authenticated'])) { if (empty($_SESSION['authenticated'])) {
http_response_code(401); http_response_code(401);
echo json_encode(['error' => 'Unauthorized']); echo json_encode(['error' => 'Unauthorized']);
return; return;
} }
$username = (string)($_SESSION['username'] ?? ''); $username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: []; $userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms); $isAdmin = ACL::isAdmin($userPerms);
// Admins should never be blocked by account-level "disableUpload" // Admins should never be blocked by account-level "disableUpload"
if (!$isAdmin && !empty($userPerms['disableUpload'])) { if (!$isAdmin && !empty($userPerms['disableUpload'])) {
http_response_code(403); http_response_code(403);
echo json_encode(['error' => 'Upload disabled for this user.']); echo json_encode(['error' => 'Upload disabled for this user.']);
return; return;
} }
// ---- 3) Folder-level WRITE permission (ACL) ---- // ---- 3) Folder-level WRITE permission (ACL) ----
// Always require client to send the folder; fall back to GET if needed. // Prefer the unified param array, fall back to GET only if needed.
$folderParam = isset($_POST['folder']) $folderParam = isset($requestParams['folder'])
? (string)$_POST['folder'] ? (string)$requestParams['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root'); : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Decode %xx (e.g., "test%20folder") then normalize // Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam); $folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam); $targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks // Admins bypass folder canWrite checks
$username = (string)($_SESSION['username'] ?? ''); if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
$userPerms = loadUserPermissions($username) ?: []; http_response_code(403);
$isAdmin = ACL::isAdmin($userPerms); echo json_encode([
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
]);
return;
}
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) { // ---- 4) Delegate to model (force the sanitized folder) ----
http_response_code(403); $requestParams['folder'] = $targetFolder;
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']); // Keep legacy behavior for anything still reading $_POST directly
return; $_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) ---- public function removeChunks(): void
$_POST['folder'] = $targetFolder; // in case model reads superglobal {
$post = $_POST; header('Content-Type: application/json');
$post['folder'] = $targetFolder;
$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($_POST['folder'])) {
if (isset($result['error'])) { http_response_code(400);
http_response_code(400); echo json_encode(['error' => 'No folder specified']);
echo json_encode($result); return;
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));
}
} }

View File

@@ -3,14 +3,17 @@
require_once PROJECT_ROOT . '/config/config.php'; require_once PROJECT_ROOT . '/config/config.php';
class UploadModel { class UploadModel
{
private static function sanitizeFolder(string $folder): string { private static function sanitizeFolder(string $folder): string
{
// decode "%20", normalise slashes & trim via ACL helper // decode "%20", normalise slashes & trim via ACL helper
$f = ACL::normalizeFolder(rawurldecode($folder)); $f = ACL::normalizeFolder(rawurldecode($folder));
// model uses '' to represent root // model uses '' to represent root
if ($f === 'root') return ''; if ($f === 'root') {
return '';
}
// forbid dot segments / empty parts // forbid dot segments / empty parts
foreach (explode('/', $f) as $seg) { foreach (explode('/', $f) as $seg) {
@@ -28,9 +31,13 @@ class UploadModel {
return $f; // safe, normalised, with spaces allowed return $f; // safe, normalised, with spaces allowed
} }
public static function handleUpload(array $post, array $files): array { public static function handleUpload(array $post, array $files): array
// --- GET resumable test (make folder handling consistent) {
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) { // --- GET resumable test (make folder handling consistent) ---
if (
(($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET')
&& isset($post['resumableChunkNumber'], $post['resumableIdentifier'])
) {
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0); $chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
$resumableIdentifier = $post['resumableIdentifier'] ?? ''; $resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root')); $folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
@@ -38,15 +45,16 @@ class UploadModel {
$baseUploadDir = UPLOAD_DIR; $baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') { if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR $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; $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'])) { if (isset($post['resumableChunkNumber'])) {
$chunkNumber = (int)$post['resumableChunkNumber']; $chunkNumber = (int)$post['resumableChunkNumber'];
$totalChunks = (int)$post['resumableTotalChunks']; $totalChunks = (int)$post['resumableTotalChunks'];
@@ -54,109 +62,126 @@ class UploadModel {
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? '')); $resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
if (!preg_match(REGEX_FILE_NAME, $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')); $folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
if (empty($files['file']) || !isset($files['file']['name'])) { if (empty($files['file']) || !isset($files['file']['name'])) {
return ["error" => "No files received"]; return ['error' => 'No files received'];
} }
$baseUploadDir = UPLOAD_DIR; $baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') { if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR $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)) { 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; $tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true)) { 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; $chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
if ($chunkErr !== UPLOAD_ERR_OK) { if ($chunkErr !== UPLOAD_ERR_OK) {
return ["error" => "Upload error on chunk $chunkNumber"]; return ['error' => "Upload error on chunk $chunkNumber"];
} }
$chunkFile = $tempDir . $chunkNumber; $chunkFile = $tempDir . $chunkNumber;
$tmpName = $files['file']['tmp_name'] ?? null; $tmpName = $files['file']['tmp_name'] ?? null;
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) { 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++) { for ($i = 1; $i <= $totalChunks; $i++) {
if (!file_exists($tempDir . $i)) { if (!file_exists($tempDir . $i)) {
return ["status" => "chunk uploaded"]; return ['status' => 'chunk uploaded'];
} }
} }
// merge // Merge
$targetPath = $baseUploadDir . $resumableFilename; $targetPath = $baseUploadDir . $resumableFilename;
if (!$out = fopen($targetPath, "wb")) { if (!$out = fopen($targetPath, 'wb')) {
return ["error" => "Failed to open target file for writing"]; return ['error' => 'Failed to open target file for writing'];
} }
for ($i = 1; $i <= $totalChunks; $i++) { for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i; $chunkPath = $tempDir . $i;
if (!file_exists($chunkPath)) { fclose($out); return ["error" => "Chunk $i missing during merge"]; } if (!file_exists($chunkPath)) {
if (!$in = fopen($chunkPath, "rb")) { fclose($out); return ["error" => "Failed to open chunk $i"]; } fclose($out);
while ($buff = fread($in, 4096)) { fwrite($out, $buff); } 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($in);
} }
fclose($out); fclose($out);
// metadata // Metadata
$metadataKey = ($folderSan === '') ? "root" : $folderSan; $metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json'; $metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName; $metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT); $uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown"; $uploader = $_SESSION['username'] ?? 'Unknown';
$collection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : []; $collection = file_exists($metadataFile)
if (!is_array($collection)) $collection = []; ? json_decode(file_get_contents($metadataFile), true)
: [];
if (!is_array($collection)) {
$collection = [];
}
if (!isset($collection[$resumableFilename])) { 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)); file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
} }
// cleanup temp // Cleanup temp
self::rrmdir($tempDir); 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')); $folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = UPLOAD_DIR; $baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') { if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR $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)) { 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; $safeFileNamePattern = REGEX_FILE_NAME;
$metadataCollection = []; $metadataCollection = [];
$metadataChanged = []; $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) { 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))); $safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) { if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName]; return ['error' => 'Invalid file name: ' . $fileName];
} }
$relativePath = ''; $relativePath = '';
if (isset($post['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; $uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
@@ -164,34 +189,41 @@ class UploadModel {
$subDir = dirname($relativePath); $subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') { if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR $uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR; . str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
} }
$safeFileName = basename($relativePath); $safeFileName = basename($relativePath);
} }
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) { 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; $targetPath = $uploadDir . $safeFileName;
if (!move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) { if (!move_uploaded_file($files['file']['tmp_name'][$index], $targetPath)) {
return ["error" => "Error uploading file"]; return ['error' => 'Error uploading file'];
} }
$metadataKey = ($folderSan === '') ? "root" : $folderSan; $metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json'; $metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName; $metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) { if (!isset($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : []; $metadataCollection[$metadataKey] = file_exists($metadataFile)
if (!is_array($metadataCollection[$metadataKey])) $metadataCollection[$metadataKey] = []; ? json_decode(file_get_contents($metadataFile), true)
: [];
if (!is_array($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = [];
}
$metadataChanged[$metadataKey] = false; $metadataChanged[$metadataKey] = false;
} }
if (!isset($metadataCollection[$metadataKey][$safeFileName])) { if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT); $uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown"; $uploader = $_SESSION['username'] ?? 'Unknown';
$metadataCollection[$metadataKey][$safeFileName] = ["uploaded" => $uploadedDate, "uploader" => $uploader]; $metadataCollection[$metadataKey][$safeFileName] = [
'uploaded' => $uploadedDate,
'uploader' => $uploader,
];
$metadataChanged[$metadataKey] = true; $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. * Recursively removes a directory and its contents.
* *
* @param string $dir The directory to remove. * @param string $dir The directory to remove.
* @return void * @return void
*/ */
private static function rrmdir(string $dir): void { private static function rrmdir(string $dir): void
{
if (!is_dir($dir)) { if (!is_dir($dir)) {
return; return;
} }
@@ -231,7 +263,7 @@ class UploadModel {
} }
rmdir($dir); rmdir($dir);
} }
/** /**
* Removes the temporary chunk directory for resumable uploads. * Removes the temporary chunk directory for resumable uploads.
* *
@@ -240,25 +272,26 @@ class UploadModel {
* @param string $folder The folder name provided (URL-decoded). * @param string $folder The folder name provided (URL-decoded).
* @return array Returns a status array indicating success or error. * @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); $folder = urldecode($folder);
// The folder name should exactly match the "resumable_" pattern. // The folder name should exactly match the "resumable_" pattern.
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u"; $regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
if (!preg_match($regex, $folder)) { if (!preg_match($regex, $folder)) {
return ["error" => "Invalid folder name"]; return ['error' => 'Invalid folder name'];
} }
$tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder; $tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
if (!is_dir($tempDir)) { if (!is_dir($tempDir)) {
return ["success" => true, "message" => "Temporary folder already removed."]; return ['success' => true, 'message' => 'Temporary folder already removed.'];
} }
self::rrmdir($tempDir); self::rrmdir($tempDir);
if (!is_dir($tempDir)) { if (!is_dir($tempDir)) {
return ["success" => true, "message" => "Temporary folder removed."]; return ['success' => true, 'message' => 'Temporary folder removed.'];
} else {
return ["error" => "Failed to remove temporary folder."];
} }
return ['error' => 'Failed to remove temporary folder.'];
} }
} }