release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67)
This commit is contained in:
50
CHANGELOG.md
50
CHANGELOG.md
@@ -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 Resumable’s internal queue, clear draft records for the folder, and re-show the resumable banner only when appropriate.
|
||||||
|
|
||||||
|
- Hiding resumable temp folders
|
||||||
|
- Hide `resumable_…` folders alongside `trash` and `profile_pics` in:
|
||||||
|
- Folder tree BFS traversal (child discovery / recursion).
|
||||||
|
- `listChildren.php` results and child-cache hydration.
|
||||||
|
- The inline folder strip above the file list (also filtered in `fileListView.js`).
|
||||||
|
|
||||||
|
- Folder manager context menu upgrade
|
||||||
|
- Replace the old ad-hoc folder context menu with a unified `filr-menu` implementation that mirrors the file context menu styling.
|
||||||
|
- Add Material icon mapping per action (`create_folder`, `move_folder`, `rename_folder`, `color_folder`, `folder_share`, `delete_folder`) and clamp the menu to viewport with escape/outside-click close behavior.
|
||||||
|
- Wire the new menu from both tree nodes and breadcrumb links, respecting locked folders and current folder capabilities.
|
||||||
|
|
||||||
|
- File context menu & selection logic
|
||||||
|
- Define a semantic file context menu in `index.html` (`#fileContextMenu` with `.filr-menu` buttons, icons, `data-action`, and `data-when` visibility flags).
|
||||||
|
- Rebuild `fileMenu.js` to:
|
||||||
|
- Derive the current selection from file checkboxes and map back to real `fileData` entries, handling the encoded row IDs.
|
||||||
|
- Toggle menu items based on selection state (`any`, `one`, `many`, `zip`, `can-edit`) and hide redundant separators.
|
||||||
|
- Position the menu within the viewport, add ESC/outside-click dismissal, and delegate click handling to call the existing file actions (preview, edit, rename, copy/move/delete/download/extract, tag single/multiple).
|
||||||
|
|
||||||
|
- Tagging system robustness
|
||||||
|
- Refactor `fileTags.js` to enforce single-instance modals for both single-file and multi-file tagging, preventing duplicate DOM nodes and double bindings.
|
||||||
|
- Centralize global tag storage (`window.globalTags` + `localStorage`) with shared dropdowns for both modals, including “×” removal for global tags that syncs back to the server.
|
||||||
|
- Make the tag modals safer and more idempotent (re-usable DOM, Esc and backdrop-to-close, defensive checks on elements) while keeping the existing file row badge rendering and tag-based filtering behavior.
|
||||||
|
- Localize various tag-related strings where possible and ensure gallery + table views stay in sync after tag changes.
|
||||||
|
|
||||||
|
- Visual polish & theming
|
||||||
|
- Introduce a shared `--menu-radius` token and apply it across login form, file list container, restore modal, preview modals, OnlyOffice modal, user dropdown menus, and the Upload / Folder Management cards for consistent rounded corners.
|
||||||
|
- Update header button hover to use the same soft blue hover as other interactive elements and tune card shadows for light vs dark mode.
|
||||||
|
- Adjust media preview modal background to a darker neutral and tweak `filePreview` panel background fallback (`--panel-bg` / `--bg-color`) for better dark mode contrast.
|
||||||
|
- Style `.filr-menu` for both file + folder menus with max-height, scrolling, proper separators, and Material icons inheriting text color in light and dark themes.
|
||||||
|
- Align the user dropdown menu hover/active styles with the new menu hover tokens (`--filr-row-hover-bg`, `--filr-row-outline-hover`) for a consistent interaction feel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 11/13/2025 (v1.9.5)
|
## 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
|
||||||
|
|||||||
@@ -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 don’t poke past rounded edges */
|
||||||
|
border: 1px solid var(--card-border, #e5e7eb);
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode polish */
|
||||||
|
body.dark-mode #uploadCard,
|
||||||
|
body.dark-mode #folderManagementCard {
|
||||||
|
border-color: var(--card-border-dark, #3a3a3a);
|
||||||
|
background: var(--card-bg-dark, #2c2c2c);
|
||||||
|
box-shadow: 0 12px 28px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
#uploadFolderRow.highlight {
|
||||||
min-height: 320px;
|
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 */
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>";
|
||||||
|
|||||||
@@ -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 can’t overlay the modal
|
||||||
|
hideFileContextMenu();
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const s = window.__filr_ctx_state || currentSelection();
|
||||||
|
const folder = window.currentFolder || 'root';
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'create_file': openCreateFileModal(); break;
|
||||||
|
case 'delete_selected': handleDeleteSelected(new Event('click')); break;
|
||||||
|
case 'copy_selected': handleCopySelected(new Event('click')); break;
|
||||||
|
case 'move_selected': handleMoveSelected(new Event('click')); break;
|
||||||
|
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
|
||||||
|
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
|
||||||
|
|
||||||
|
case 'tag_selected':
|
||||||
|
openMultiTagModal(s.files); // s.files are the real file objects
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'preview':
|
||||||
|
if (s.file) previewFile(buildPreviewUrl(folder, s.file.name), s.file.name);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'edit':
|
||||||
|
if (s.file && s.canEdit) editFile(s.file.name, folder);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rename':
|
||||||
|
if (s.file) renameFile(s.file.name, folder);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tag_file':
|
||||||
|
if (s.file) openTagModal(s.file);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep your renderFileTable wrapper as-is
|
||||||
|
|
||||||
export function bindFileListContextMenu() {
|
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 });
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
@@ -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)';
|
||||||
|
|||||||
@@ -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">×</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">×</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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 Drag–and–Drop Folder Uploads (Original Code)
|
Helpers for Drag–and–Drop 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 };
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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.'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user