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