From ca4037da8fcc93eec75c9796e2cd36de5fedb0eb Mon Sep 17 00:00:00 2001 From: George Wu Date: Tue, 24 Feb 2026 17:56:25 -0800 Subject: [PATCH 1/3] feat: improve move dialog with folder navigation - Remove 'Root' option from move dialog (users move within their home folder) - Show children of current folder instead of flat folder list - Add breadcrumb navigation for folder browsing - Add 'go to parent' navigation option - Add 'select this folder' option to choose current location - Add CSS styles for new navigation elements - Add dark mode support for move dialog - Add i18n translations for new strings (en, es) --- static/css/components/dialogs.css | 180 +++++++++++++++ static/js/app/ui.js | 6 +- static/js/features/files/contextMenus.js | 269 ++++++++++++++++++++--- static/locales/en.json | 3 + static/locales/es.json | 3 + 5 files changed, 424 insertions(+), 37 deletions(-) diff --git a/static/css/components/dialogs.css b/static/css/components/dialogs.css index 2b89e032..fabec313 100644 --- a/static/css/components/dialogs.css +++ b/static/css/components/dialogs.css @@ -341,6 +341,45 @@ margin-bottom: 15px; } +/* Move dialog breadcrumb navigation */ +.move-dialog-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px; + padding: 8px 12px; + background: #f8fafc; + border-radius: 8px; + margin-bottom: 12px; + font-size: 13px; + overflow-x: auto; +} + +.move-breadcrumb-item { + color: #4a5568; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.15s ease; + white-space: nowrap; +} + +.move-breadcrumb-item:hover { + background: #e2e8f0; + color: #1a202c; +} + +.move-breadcrumb-item.current { + color: #ff5e3a; + font-weight: 600; + cursor: default; +} + +.move-breadcrumb-separator { + color: #a0aec0; + margin: 0 2px; +} + /* Folder select items in move dialog */ .folder-select-item { display: flex; @@ -373,6 +412,70 @@ color: #ff5e3a; } +/* "Select this folder" option */ +.folder-select-item.folder-select-current { + background-color: rgba(72, 187, 120, 0.1); + color: #2f855a; + font-weight: 500; +} + +.folder-select-item.folder-select-current:hover { + background-color: rgba(72, 187, 120, 0.15); +} + +.folder-select-item.folder-select-current i { + color: #48bb78; +} + +/* Navigate up option */ +.folder-select-item.folder-navigate-up { + color: #718096; + font-style: italic; +} + +.folder-select-item.folder-navigate-up:hover { + color: #4a5568; +} + +.folder-select-item.folder-navigate-up i { + color: #a0aec0; +} + +/* Folder navigation item (click to enter) */ +.folder-select-item.folder-navigate { + justify-content: space-between; +} + +.folder-select-item.folder-navigate .folder-name { + flex: 1; +} + +.folder-select-item.folder-navigate .folder-navigate-icon { + color: #a0aec0; + font-size: 12px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.folder-select-item.folder-navigate:hover .folder-navigate-icon { + opacity: 1; +} + +/* Empty folder message */ +.folder-select-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px; + color: #a0aec0; + font-size: 14px; +} + +.folder-select-empty i { + font-size: 20px; +} + /* Custom confirm dialog */ .confirm-dialog { position: fixed; @@ -747,3 +850,80 @@ [data-theme="dark"] .share-item-info { color: #e2e8f0; } + +/* Dark mode for move dialog breadcrumb */ +[data-theme="dark"] .move-dialog-breadcrumb { + background: #0f172a; +} + +[data-theme="dark"] .move-breadcrumb-item { + color: #94a3b8; +} + +[data-theme="dark"] .move-breadcrumb-item:hover { + background: #334155; + color: #e2e8f0; +} + +[data-theme="dark"] .move-breadcrumb-item.current { + color: #ff5e3a; +} + +[data-theme="dark"] .move-breadcrumb-separator { + color: #475569; +} + +/* Dark mode for folder select items */ +[data-theme="dark"] .folder-select-item { + color: #cbd5e1; +} + +[data-theme="dark"] .folder-select-item:hover { + background-color: #334155; +} + +[data-theme="dark"] .folder-select-item.selected { + background-color: rgba(255,94,58,0.15); + color: #ff5e3a; +} + +[data-theme="dark"] .folder-select-item i { + color: #fbbf24; +} + +[data-theme="dark"] .folder-select-item.selected i { + color: #ff5e3a; +} + +[data-theme="dark"] .folder-select-item.folder-select-current { + background-color: rgba(74, 222, 128, 0.1); + color: #86efac; +} + +[data-theme="dark"] .folder-select-item.folder-select-current:hover { + background-color: rgba(74, 222, 128, 0.15); +} + +[data-theme="dark"] .folder-select-item.folder-select-current i { + color: #4ade80; +} + +[data-theme="dark"] .folder-select-item.folder-navigate-up { + color: #64748b; +} + +[data-theme="dark"] .folder-select-item.folder-navigate-up:hover { + color: #94a3b8; +} + +[data-theme="dark"] .folder-select-item.folder-navigate-up i { + color: #475569; +} + +[data-theme="dark"] .folder-select-item.folder-navigate .folder-navigate-icon { + color: #475569; +} + +[data-theme="dark"] .folder-select-empty { + color: #475569; +} diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 002fafb4..9f7371b3 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -102,7 +102,7 @@ const ui = { document.body.appendChild(renameDialog); } - // Move dialog — modern + // Move dialog — modern with navigation if (!document.getElementById('move-file-dialog')) { const moveDialog = document.createElement('div'); moveDialog.className = 'rename-dialog'; @@ -115,10 +115,8 @@ const ui = {

Select destination folder:

+
-
- Root -
diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index b37dc8cb..316dfaf5 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -395,6 +395,24 @@ const contextMenus = { // Reset selection window.app.selectedTargetFolderId = ""; + // Initialize dialog navigation state + // Start at the parent of the item being moved (so user sees siblings and can navigate) + let startFolderId = null; + if (mode === 'file' && item.folder_id) { + startFolderId = item.folder_id; + } else if (mode === 'folder' && item.parent_id) { + startFolderId = item.parent_id; + } else { + // If item is at root level, start at user's home folder + startFolderId = window.app.userHomeFolderId || null; + } + + // Store the item being moved and navigation state + window.app.moveDialogItemId = item.id; + window.app.moveDialogItemMode = mode; + window.app.moveDialogCurrentFolderId = startFolderId; + window.app.moveDialogBreadcrumb = []; + // Update dialog title (preserve icon) const dialogHeader = document.getElementById('move-file-dialog').querySelector('.rename-dialog-header'); const titleText = mode === 'file' ? @@ -402,8 +420,8 @@ const contextMenus = { (window.i18n ? window.i18n.t('dialogs.move_folder') : 'Move folder'); dialogHeader.innerHTML = ` ${titleText}`; - // Load all available folders - await this.loadAllFolders(item.id, mode); + // Load folders for the starting location + await this.loadMoveDialogFolders(startFolderId); // Show dialog document.getElementById('move-file-dialog').style.display = 'flex'; @@ -456,11 +474,220 @@ const contextMenus = { }, /** - * Load all folders for the move dialog + * Load folders for the move dialog with navigation support + * Shows subfolders of the specified parent folder and allows navigation + * @param {string} parentFolderId - Parent folder ID to load children from (null for root) + */ + async loadMoveDialogFolders(parentFolderId) { + try { + const token = localStorage.getItem('oxicloud_token'); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + + // Build URL - use /api/folders/{id}/contents for subfolders, or /api/folders for root + let url; + if (parentFolderId) { + url = `/api/folders/${parentFolderId}/contents`; + } else { + url = '/api/folders'; + } + + const response = await fetch(url, { headers }); + if (!response.ok) { + console.error('Failed to load folders:', response.status); + return; + } + + const data = await response.json(); + // The contents endpoint returns { folders: [...], files: [...] }, but we only need folders + const folders = Array.isArray(data) ? data : (data.folders || []); + + const folderSelectContainer = document.getElementById('folder-select-container'); + const breadcrumbContainer = document.getElementById('move-dialog-breadcrumb'); + + // Clear container + folderSelectContainer.innerHTML = ''; + + // Get current navigation state + const itemId = window.app.moveDialogItemId; + const mode = window.app.moveDialogItemMode; + const breadcrumb = window.app.moveDialogBreadcrumb || []; + + // Render breadcrumb navigation + this._renderMoveDialogBreadcrumb(breadcrumbContainer, breadcrumb, parentFolderId); + + // Option to select current folder as destination (if not at root and not the item being moved) + if (parentFolderId && parentFolderId !== itemId) { + const currentFolderOption = document.createElement('div'); + currentFolderOption.className = 'folder-select-item folder-select-current'; + currentFolderOption.innerHTML = ` + + ${window.i18n ? window.i18n.t('dialogs.select_this_folder') : 'Select this folder'} + `; + currentFolderOption.addEventListener('click', () => { + document.querySelectorAll('.folder-select-item').forEach(item => { + item.classList.remove('selected'); + }); + currentFolderOption.classList.add('selected'); + window.app.selectedTargetFolderId = parentFolderId; + }); + folderSelectContainer.appendChild(currentFolderOption); + } + + // Add "Go to parent" option if not at root + if (parentFolderId) { + const parentOption = document.createElement('div'); + parentOption.className = 'folder-select-item folder-navigate-up'; + parentOption.innerHTML = ` + + ${window.i18n ? window.i18n.t('dialogs.go_to_parent') : '.. (parent folder)'} + `; + parentOption.addEventListener('click', () => { + // Navigate to parent folder + const currentBreadcrumb = window.app.moveDialogBreadcrumb || []; + if (currentBreadcrumb.length > 0) { + // Remove current folder from breadcrumb + currentBreadcrumb.pop(); + const parentFolder = currentBreadcrumb.length > 0 + ? currentBreadcrumb[currentBreadcrumb.length - 1] + : null; + window.app.moveDialogBreadcrumb = currentBreadcrumb; + window.app.moveDialogCurrentFolderId = parentFolder ? parentFolder.id : null; + this.loadMoveDialogFolders(parentFolder ? parentFolder.id : null); + } else { + // Go to root (home folder) + window.app.moveDialogCurrentFolderId = window.app.userHomeFolderId || null; + this.loadMoveDialogFolders(window.app.userHomeFolderId || null); + } + }); + folderSelectContainer.appendChild(parentOption); + } + + // Add subfolders (clicking navigates INTO the folder) + folders.forEach(folder => { + // Skip the item being moved (to prevent moving a folder into itself) + if (mode === 'folder' && folder.id === itemId) { + return; + } + + const folderItem = document.createElement('div'); + folderItem.className = 'folder-select-item folder-navigate'; + folderItem.dataset.folderId = folder.id; + folderItem.innerHTML = ` + + ${escapeHtml(folder.name)} + + `; + + // Click navigates INTO this folder + folderItem.addEventListener('click', () => { + // Add to breadcrumb + const breadcrumb = window.app.moveDialogBreadcrumb || []; + breadcrumb.push({ id: folder.id, name: folder.name }); + window.app.moveDialogBreadcrumb = breadcrumb; + window.app.moveDialogCurrentFolderId = folder.id; + this.loadMoveDialogFolders(folder.id); + }); + + folderSelectContainer.appendChild(folderItem); + }); + + // Show "no subfolders" message if empty + if (folders.length === 0 && !parentFolderId) { + const emptyMsg = document.createElement('div'); + emptyMsg.className = 'folder-select-empty'; + emptyMsg.innerHTML = ` ${window.i18n ? window.i18n.t('dialogs.no_subfolders') : 'No subfolders'}`; + folderSelectContainer.appendChild(emptyMsg); + } + + // Set default selection to current folder + window.app.selectedTargetFolderId = parentFolderId || ''; + + // Translate new elements + if (window.i18n && window.i18n.translateElement) { + window.i18n.translateElement(folderSelectContainer); + } + } catch (error) { + console.error('Error loading folders:', error); + } + }, + + /** + * Render breadcrumb navigation for move dialog + */ + _renderMoveDialogBreadcrumb(container, breadcrumb, currentFolderId) { + if (!container) return; + container.innerHTML = ''; + + const homeFolderId = window.app.userHomeFolderId; + const homeFolderName = window.app.userHomeFolderName || 'Home'; + + // Home icon (click to go to home folder) + const homeItem = document.createElement('span'); + homeItem.className = 'move-breadcrumb-item'; + homeItem.innerHTML = ''; + homeItem.addEventListener('click', () => { + window.app.moveDialogBreadcrumb = []; + window.app.moveDialogCurrentFolderId = homeFolderId || null; + this.loadMoveDialogFolders(homeFolderId || null); + }); + container.appendChild(homeItem); + + // Home folder name + if (homeFolderName) { + const separator = document.createElement('span'); + separator.className = 'move-breadcrumb-separator'; + separator.textContent = '>'; + container.appendChild(separator); + + const homeNameItem = document.createElement('span'); + homeNameItem.className = 'move-breadcrumb-item'; + if (breadcrumb.length === 0) { + homeNameItem.classList.add('current'); + } + homeNameItem.textContent = homeFolderName; + if (breadcrumb.length > 0) { + homeNameItem.addEventListener('click', () => { + window.app.moveDialogBreadcrumb = []; + window.app.moveDialogCurrentFolderId = homeFolderId || null; + this.loadMoveDialogFolders(homeFolderId || null); + }); + } + container.appendChild(homeNameItem); + } + + // Breadcrumb path + breadcrumb.forEach((segment, index) => { + const separator = document.createElement('span'); + separator.className = 'move-breadcrumb-separator'; + separator.textContent = '>'; + container.appendChild(separator); + + const item = document.createElement('span'); + item.className = 'move-breadcrumb-item'; + if (index === breadcrumb.length - 1) { + item.classList.add('current'); + } + item.textContent = segment.name; + + // Click to navigate back to this level + if (index < breadcrumb.length - 1) { + item.addEventListener('click', () => { + window.app.moveDialogBreadcrumb = breadcrumb.slice(0, index + 1); + window.app.moveDialogCurrentFolderId = segment.id; + this.loadMoveDialogFolders(segment.id); + }); + } + container.appendChild(item); + }); + }, + + /** + * Load all folders for the move dialog (legacy - kept for batch operations) * @param {string} itemId - ID of the item being moved * @param {string} mode - 'file' or 'folder' */ async loadAllFolders(itemId, mode) { + // For batch mode, use the old behavior but start from home folder try { const token = localStorage.getItem('oxicloud_token'); const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; @@ -469,32 +696,15 @@ const contextMenus = { const folders = await response.json(); const folderSelectContainer = document.getElementById('folder-select-container'); - // Clear container except root option - folderSelectContainer.innerHTML = ` -
- Root -
- `; - - // Select root by default - window.app.selectedTargetFolderId = ""; + // Clear container + folderSelectContainer.innerHTML = ''; // Add all available folders if (Array.isArray(folders)) { + const excludeIds = itemId ? [itemId] : []; folders.forEach(folder => { // Skip folders that would create cycles - if (mode === 'folder' && folder.id === itemId) { - return; - } - - // Skip current folder of the item - if (mode === 'file' && window.app.contextMenuTargetFile && - folder.id === window.app.contextMenuTargetFile.folder_id) { - return; - } - - if (mode === 'folder' && window.app.contextMenuTargetFolder && - folder.id === window.app.contextMenuTargetFolder.parent_id) { + if (excludeIds.includes(folder.id)) { return; } @@ -518,15 +728,8 @@ const contextMenus = { }); } - // Event for root option - const rootOption = folderSelectContainer.querySelector('.folder-select-item'); - rootOption.addEventListener('click', () => { - document.querySelectorAll('.folder-select-item').forEach(item => { - item.classList.remove('selected'); - }); - rootOption.classList.add('selected'); - window.app.selectedTargetFolderId = ""; - }); + // Set default selection + window.app.selectedTargetFolderId = ""; // Translate new elements (scoped to container) if (window.i18n && window.i18n.translateElement) { diff --git a/static/locales/en.json b/static/locales/en.json index 8dd5f3be..2cbe3d97 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -202,6 +202,9 @@ "move_file": "Move file", "move_folder": "Move folder", "select_destination": "Select destination folder:", + "select_this_folder": "Select this folder", + "go_to_parent": ".. (parent folder)", + "no_subfolders": "No subfolders", "root": "Root", "delete_confirmation": "Are you sure you want to delete", "and_contents": "and all its contents", diff --git a/static/locales/es.json b/static/locales/es.json index 2b991bab..9029bd8b 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -202,6 +202,9 @@ "move_file": "Mover archivo", "move_folder": "Mover carpeta", "select_destination": "Selecciona la carpeta destino:", + "select_this_folder": "Seleccionar esta carpeta", + "go_to_parent": ".. (carpeta superior)", + "no_subfolders": "Sin subcarpetas", "root": "Raíz", "delete_confirmation": "¿Estás seguro de que quieres eliminar", "and_contents": "y todo su contenido", From 5824b6c4b45278f4522edd0682a8d77f66c121dc Mon Sep 17 00:00:00 2001 From: George Wu Date: Tue, 24 Feb 2026 18:08:33 -0800 Subject: [PATCH 2/3] Add navigation and copy functionality to move dialog - Add breadcrumb navigation to move dialog for folder navigation - Add 'Copy' button alongside 'Move' button in the dialog - Implement copyFile and copyFolder functions in fileOps - Add copy handler for batch operations - Add CSS styles for btn-outline button (light and dark mode) - Add translations for new dialog strings fix: properly show home folder contents in move dialog - Use effectiveParentId for all checks and rendering - Show 'Select this folder' option for home folder - Only show 'go to parent' when breadcrumb has items (navigated into subfolders) fix: improve move dialog UX - Hide breadcrumb at home folder level (not needed) - Only show 'Select this folder' option after navigating into subfolders - Show 'no subfolders' message when there are no folders to navigate - Properly display subfolders for navigation --- static/css/components/dialogs.css | 24 ++- static/js/app/ui.js | 1 + static/js/features/files/contextMenus.js | 216 ++++++++++++++++----- static/js/features/files/fileOperations.js | 57 ++++++ 4 files changed, 247 insertions(+), 51 deletions(-) diff --git a/static/css/components/dialogs.css b/static/css/components/dialogs.css index fabec313..a8689440 100644 --- a/static/css/components/dialogs.css +++ b/static/css/components/dialogs.css @@ -77,6 +77,17 @@ border-top: 1px solid #e2e8f0; } +.rename-dialog-buttons .btn-outline { + background: transparent; + color: #4a5568; + border: 1px solid #cbd5e0; +} + +.rename-dialog-buttons .btn-outline:hover { + background: #f7fafc; + border-color: #a0aec0; +} + /* Share Dialog — Modern Style */ .share-dialog { position: fixed; @@ -343,7 +354,7 @@ /* Move dialog breadcrumb navigation */ .move-dialog-breadcrumb { - display: flex; + display: none; /* Hidden by default, shown via JS when navigating into subfolders */ align-items: center; flex-wrap: wrap; gap: 4px; @@ -657,6 +668,17 @@ border-top-color: #334155; } +[data-theme="dark"] .rename-dialog-buttons .btn-outline { + background: transparent; + color: #94a3b8; + border-color: #475569; +} + +[data-theme="dark"] .rename-dialog-buttons .btn-outline:hover { + background: #334155; + border-color: #64748b; +} + [data-theme="dark"] .shared-dialog-content { background-color: #1e293b; } diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 9f7371b3..a649e56a 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -121,6 +121,7 @@ const ui = {
+
diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index 316dfaf5..203bea87 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -63,7 +63,7 @@ const contextMenus = { } window.ui.closeContextMenu(); }); - + document.getElementById('favorite-folder-option').addEventListener('click', async () => { if (window.app.contextMenuTargetFolder) { const folder = window.app.contextMenuTargetFolder; @@ -91,7 +91,7 @@ const contextMenus = { } window.ui.closeContextMenu(); }); - + document.getElementById('rename-folder-option').addEventListener('click', () => { if (window.app.contextMenuTargetFolder) { this.showRenameDialog(window.app.contextMenuTargetFolder); @@ -105,7 +105,7 @@ const contextMenus = { } window.ui.closeContextMenu(); }); - + document.getElementById('share-folder-option').addEventListener('click', () => { const folder = window.app.contextMenuTargetFolder; if (folder) { @@ -180,7 +180,7 @@ const contextMenus = { } window.ui.closeFileContextMenu(); }); - + document.getElementById('favorite-file-option').addEventListener('click', async () => { if (window.app.contextMenuTargetFile) { const file = window.app.contextMenuTargetFile; @@ -208,7 +208,7 @@ const contextMenus = { } window.ui.closeFileContextMenu(); }); - + document.getElementById('rename-file-option').addEventListener('click', () => { if (window.app.contextMenuTargetFile) { this.showRenameFileDialog(window.app.contextMenuTargetFile); @@ -259,8 +259,77 @@ const contextMenus = { // Move dialog events const moveCancelBtn = document.getElementById('move-cancel-btn'); const moveConfirmBtn = document.getElementById('move-confirm-btn'); + const copyConfirmBtn = document.getElementById('copy-confirm-btn'); moveCancelBtn.addEventListener('click', this.closeMoveDialog); + + // Copy button handler + copyConfirmBtn.addEventListener('click', async () => { + // Batch copy mode (from multiSelect) + if (window.app.moveDialogMode === 'batch' && window.multiSelect) { + const targetId = window.app.selectedTargetFolderId; + const items = window.app.batchMoveItems || []; + + const fileIds = items.filter(i => i.type === 'file').map(i => i.id); + const folderIds = items.filter(i => i.type === 'folder').map(i => i.id); + + let success = 0, errors = 0; + + try { + // Batch copy files + if (fileIds.length > 0) { + const res = await fetch('/api/batch/files/copy', { + method: 'POST', + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_ids: fileIds, target_folder_id: targetId }) + }); + const data = await res.json(); + success += data.stats?.successful || 0; + errors += data.stats?.failed || 0; + } + + // Note: Folder copy is not yet implemented in batch API + if (folderIds.length > 0) { + window.ui.showNotification('Info', 'Folder copy is not yet supported in batch mode'); + } + } catch (err) { + console.error('Batch copy error:', err); + errors++; + } + + this.closeMoveDialog(); + window.multiSelect.clear(); + window.loadFiles(); + + if (errors > 0) { + window.ui.showNotification('Batch copy', `${success} copied, ${errors} failed`); + } else { + window.ui.showNotification('Items copied', + `${success} item${success !== 1 ? 's' : ''} copied successfully`); + } + return; + } + + // Single item copy + if (window.app.moveDialogMode === 'file' && window.app.contextMenuTargetFile) { + const success = await window.fileOps.copyFile( + window.app.contextMenuTargetFile.id, + window.app.selectedTargetFolderId + ); + if (success) { + this.closeMoveDialog(); + } + } else if (window.app.moveDialogMode === 'folder' && window.app.contextMenuTargetFolder) { + const success = await window.fileOps.copyFolder( + window.app.contextMenuTargetFolder.id, + window.app.selectedTargetFolderId + ); + if (success) { + this.closeMoveDialog(); + } + } + }); + moveConfirmBtn.addEventListener('click', async () => { // Batch move mode (from multiSelect) if (window.app.moveDialogMode === 'batch' && window.multiSelect) { @@ -316,7 +385,7 @@ const contextMenus = { if (window.app.moveDialogMode === 'file' && window.app.contextMenuTargetFile) { const success = await window.fileOps.moveFile( - window.app.contextMenuTargetFile.id, + window.app.contextMenuTargetFile.id, window.app.selectedTargetFolderId ); if (success) { @@ -324,7 +393,7 @@ const contextMenus = { } } else if (window.app.moveDialogMode === 'folder' && window.app.contextMenuTargetFolder) { const success = await window.fileOps.moveFolder( - window.app.contextMenuTargetFolder.id, + window.app.contextMenuTargetFolder.id, window.app.selectedTargetFolderId ); if (success) { @@ -395,6 +464,12 @@ const contextMenus = { // Reset selection window.app.selectedTargetFolderId = ""; + // Ensure we have the home folder ID BEFORE calculating startFolderId + if (!window.app.userHomeFolderId) { + console.log('[Move Dialog] Home folder ID not set, resolving...'); + await window.resolveHomeFolder(); + } + // Initialize dialog navigation state // Start at the parent of the item being moved (so user sees siblings and can navigate) let startFolderId = null; @@ -407,6 +482,8 @@ const contextMenus = { startFolderId = window.app.userHomeFolderId || null; } + console.log('[Move Dialog] showMoveDialog - item:', item, 'mode:', mode, 'startFolderId:', startFolderId, 'userHomeFolderId:', window.app.userHomeFolderId); + // Store the item being moved and navigation state window.app.moveDialogItemId = item.id; window.app.moveDialogItemMode = mode; @@ -483,14 +560,27 @@ const contextMenus = { const token = localStorage.getItem('oxicloud_token'); const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - // Build URL - use /api/folders/{id}/contents for subfolders, or /api/folders for root + // Ensure we have the home folder ID before proceeding + if (!window.app.userHomeFolderId) { + console.log('[Move Dialog] Home folder ID not set, resolving...'); + await window.resolveHomeFolder(); + } + + // Build URL - use /api/folders/{id}/contents to get CHILDREN of the folder let url; - if (parentFolderId) { - url = `/api/folders/${parentFolderId}/contents`; + let effectiveParentId = parentFolderId || window.app.userHomeFolderId; + + if (effectiveParentId) { + // Use the contents endpoint to get children (not the folder itself) + url = `/api/folders/${effectiveParentId}/contents`; } else { + // Last resort fallback: this returns root folders (home folder) + // We should NOT use this as it returns the folder itself, not its contents + console.error('[Move Dialog] No folder ID available, using fallback'); url = '/api/folders'; } + console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId); const response = await fetch(url, { headers }); if (!response.ok) { console.error('Failed to load folders:', response.status); @@ -498,8 +588,12 @@ const contextMenus = { } const data = await response.json(); - // The contents endpoint returns { folders: [...], files: [...] }, but we only need folders + console.log('[Move Dialog] API response:', data); + + // The contents endpoint returns an array of child folders + // The fallback /api/folders returns root folders (home folder itself) const folders = Array.isArray(data) ? data : (data.folders || []); + console.log('[Move Dialog] Loaded folders:', folders.length, 'folders:', folders); const folderSelectContainer = document.getElementById('folder-select-container'); const breadcrumbContainer = document.getElementById('move-dialog-breadcrumb'); @@ -512,11 +606,16 @@ const contextMenus = { const mode = window.app.moveDialogItemMode; const breadcrumb = window.app.moveDialogBreadcrumb || []; - // Render breadcrumb navigation - this._renderMoveDialogBreadcrumb(breadcrumbContainer, breadcrumb, parentFolderId); + // Only show breadcrumb when we've navigated into subfolders + if (breadcrumb.length > 0) { + this._renderMoveDialogBreadcrumb(breadcrumbContainer, breadcrumb, effectiveParentId); + breadcrumbContainer.style.display = 'flex'; + } else { + breadcrumbContainer.style.display = 'none'; + } - // Option to select current folder as destination (if not at root and not the item being moved) - if (parentFolderId && parentFolderId !== itemId) { + // Option to select current folder as destination (only after navigating into subfolders) + if (breadcrumb.length > 0 && effectiveParentId && effectiveParentId !== itemId) { const currentFolderOption = document.createElement('div'); currentFolderOption.className = 'folder-select-item folder-select-current'; currentFolderOption.innerHTML = ` @@ -528,13 +627,13 @@ const contextMenus = { item.classList.remove('selected'); }); currentFolderOption.classList.add('selected'); - window.app.selectedTargetFolderId = parentFolderId; + window.app.selectedTargetFolderId = effectiveParentId; }); folderSelectContainer.appendChild(currentFolderOption); } - // Add "Go to parent" option if not at root - if (parentFolderId) { + // Add "Go to parent" option if we have navigated into subfolders + if (breadcrumb.length > 0) { const parentOption = document.createElement('div'); parentOption.className = 'folder-select-item folder-navigate-up'; parentOption.innerHTML = ` @@ -591,11 +690,28 @@ const contextMenus = { folderSelectContainer.appendChild(folderItem); }); - // Show "no subfolders" message if empty - if (folders.length === 0 && !parentFolderId) { + // Show "no subfolders" message if there are no folders to navigate + if (folders.length === 0 && breadcrumb.length === 0) { + // At home folder level with no subfolders - show option to move here + const homeOption = document.createElement('div'); + homeOption.className = 'folder-select-item folder-select-current'; + homeOption.innerHTML = ` + + ${window.i18n ? window.i18n.t('dialogs.move_to_home') : 'Move to Home folder'} + `; + homeOption.addEventListener('click', () => { + document.querySelectorAll('.folder-select-item').forEach(item => { + item.classList.remove('selected'); + }); + homeOption.classList.add('selected'); + window.app.selectedTargetFolderId = ''; // Empty means root/home + }); + folderSelectContainer.appendChild(homeOption); + } else if (folders.length === 0) { + // Inside a subfolder with no children - show empty message const emptyMsg = document.createElement('div'); emptyMsg.className = 'folder-select-empty'; - emptyMsg.innerHTML = ` ${window.i18n ? window.i18n.t('dialogs.no_subfolders') : 'No subfolders'}`; + emptyMsg.innerHTML = ` ${window.i18n ? window.i18n.t('dialogs.no_subfolders') : 'No subfolders to navigate'}`; folderSelectContainer.appendChild(emptyMsg); } @@ -770,7 +886,7 @@ const contextMenus = { const itemName = document.getElementById('shared-item-name'); if (itemName) itemName.textContent = item.name; - + // Reset form const pwField = document.getElementById('share-password'); const expField = document.getElementById('share-expiration'); @@ -782,30 +898,30 @@ const contextMenus = { if (permRead) permRead.checked = true; if (permWrite) permWrite.checked = false; if (permReshare) permReshare.checked = false; - + // Store the current item and type for use when creating the share window.app.shareDialogItem = item; window.app.shareDialogItemType = itemType; - + // Check if item already has shares (async API call) const existingShares = await window.fileSharing.getSharedLinksForItem(item.id, itemType); const existingSharesContainer = document.getElementById('existing-shares-container'); - + // Clear existing shares container existingSharesContainer.innerHTML = ''; - + if (existingShares.length > 0) { document.getElementById('existing-shares-section').style.display = 'block'; - + // Create elements for each existing share existingShares.forEach(share => { const shareEl = document.createElement('div'); shareEl.className = 'existing-share-item'; - - const expiresText = share.expires_at ? - `Expires: ${window.fileSharing.formatExpirationDate(share.expires_at)}` : + + const expiresText = share.expires_at ? + `Expires: ${window.fileSharing.formatExpirationDate(share.expires_at)}` : 'No expiration'; - + // Share URL const urlDiv = document.createElement('div'); urlDiv.className = 'share-url'; @@ -844,10 +960,10 @@ const contextMenus = { actionsDiv.appendChild(deleteBtn); shareEl.appendChild(actionsDiv); - + existingSharesContainer.appendChild(shareEl); }); - + // Add event listeners for copy and delete buttons document.querySelectorAll('.copy-link-btn').forEach(btn => { btn.addEventListener('click', (e) => { @@ -856,12 +972,12 @@ const contextMenus = { window.fileSharing.copyLinkToClipboard(url); }); }); - + document.querySelectorAll('.delete-link-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); const shareId = btn.getAttribute('data-share-id'); - + showConfirmDialog({ title: window.i18n ? window.i18n.t('dialogs.confirm_delete_share') : 'Delete link', message: window.i18n ? window.i18n.t('dialogs.confirm_delete_share_msg') : 'Are you sure you want to delete this shared link?', @@ -880,7 +996,7 @@ const contextMenus = { } else { document.getElementById('existing-shares-section').style.display = 'none'; } - + // Hide new-share section from previous use const newShareSection = document.getElementById('new-share-section'); if (newShareSection) newShareSection.style.display = 'none'; @@ -893,7 +1009,7 @@ const contextMenus = { window.ui.showNotification('Error', 'Could not open share dialog'); } }, - + /** * Create a shared link with the configured options */ @@ -902,14 +1018,14 @@ const contextMenus = { window.ui.showNotification('Error', 'Could not share the item'); return; } - + // Get values from form const password = document.getElementById('share-password').value; const expirationDate = document.getElementById('share-expiration').value; const permissionRead = document.getElementById('share-permission-read').checked; const permissionWrite = document.getElementById('share-permission-write').checked; const permissionReshare = document.getElementById('share-permission-reshare').checked; - + const item = window.app.shareDialogItem; const itemType = window.app.shareDialogItemType; @@ -953,19 +1069,19 @@ const contextMenus = { shareUrl.focus(); shareUrl.select(); } - + // Show success message window.ui.showNotification( window.i18n ? window.i18n.t('notifications.link_created') : 'Link created', window.i18n ? window.i18n.t('notifications.share_success') : 'Shared link created successfully' ); - + } catch (error) { console.error('Error creating shared link:', error); window.ui.showNotification('Error', error.message || 'Could not create shared link'); } }, - + /** * Show email notification dialog * @param {string} shareUrl - URL to share @@ -975,14 +1091,14 @@ const contextMenus = { document.getElementById('notification-share-url').textContent = shareUrl; document.getElementById('notification-email').value = ''; document.getElementById('notification-message').value = ''; - + // Store the URL for later use window.app.notificationShareUrl = shareUrl; - + // Show dialog document.getElementById('notification-dialog').style.display = 'flex'; }, - + /** * Send share notification email */ @@ -990,19 +1106,19 @@ const contextMenus = { const email = document.getElementById('notification-email').value.trim(); const message = document.getElementById('notification-message').value.trim(); const shareUrl = window.app.notificationShareUrl; - + if (!email || !shareUrl) { window.ui.showNotification('Error', 'Please enter a valid email address'); return; } - + // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { window.ui.showNotification('Error', 'Please enter a valid email address'); return; } - + try { window.fileSharing.sendShareNotification(shareUrl, email, message); document.getElementById('notification-dialog').style.display = 'none'; @@ -1011,7 +1127,7 @@ const contextMenus = { window.ui.showNotification('Error', 'Could not send notification'); } }, - + /** * Close share dialog */ @@ -1021,7 +1137,7 @@ const contextMenus = { window.app.shareDialogItem = null; window.app.shareDialogItemType = null; }, - + /** * Close notification dialog */ diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index d6e69491..009ccdfe 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -766,6 +766,63 @@ const fileOps = { } }, + /** + * Copy a file to another folder + * @param {string} fileId - File ID + * @param {string} targetFolderId - Target folder ID + * @returns {Promise} - Success status + */ + async copyFile(fileId, targetFolderId) { + try { + const response = await fetch('/api/batch/files/copy', { + method: 'POST', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + file_ids: [fileId], + target_folder_id: targetFolderId === "" ? null : targetFolderId + }) + }); + + if (response.ok) { + const result = await response.json(); + // Reload files after copying + await window.loadFiles(); + window.ui.showNotification('File copied', 'File copied successfully'); + return true; + } else { + let errorMessage = 'Unknown error'; + try { + const errorData = await response.json(); + errorMessage = errorData.error || 'Unknown error'; + } catch (e) { + errorMessage = 'Error processing server response'; + } + window.ui.showNotification('Error', `Error copying the file: ${errorMessage}`); + return false; + } + } catch (error) { + console.error('Error copying file:', error); + window.ui.showNotification('Error', 'Error copying the file'); + return false; + } + }, + + /** + * Copy a folder to another folder + * Note: Backend folder copy is not yet implemented, this shows a notification + * @param {string} folderId - Folder ID + * @param {string} targetFolderId - Target folder ID + * @returns {Promise} - Success status + */ + async copyFolder(folderId, targetFolderId) { + // Folder copy is not yet implemented in the backend + window.ui.showNotification('Not implemented', 'Folder copy is not yet supported'); + return false; + }, + /** * Rename a file * @param {string} fileId - File ID From 3ee83896d66fda6dfa2382b94bc0d612602883d5 Mon Sep 17 00:00:00 2001 From: George Wu Date: Tue, 24 Feb 2026 19:38:05 -0800 Subject: [PATCH 3/3] Add Escape key handler to close move dialog --- static/js/features/files/contextMenus.js | 131 ++++++++++------------- 1 file changed, 55 insertions(+), 76 deletions(-) diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index 203bea87..8c63e75f 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -260,9 +260,23 @@ const contextMenus = { const moveCancelBtn = document.getElementById('move-cancel-btn'); const moveConfirmBtn = document.getElementById('move-confirm-btn'); const copyConfirmBtn = document.getElementById('copy-confirm-btn'); + const moveFileDialog = document.getElementById('move-file-dialog'); moveCancelBtn.addEventListener('click', this.closeMoveDialog); + // Close move dialog on Escape key + // Store handler reference to avoid duplicate listeners + // Note: We don't use stopPropagation because all Escape handlers are on document level + // Each handler checks its own state, so multiple dialogs can be closed with multiple Escape presses + if (!window._moveDialogEscapeHandler) { + window._moveDialogEscapeHandler = (e) => { + if (e.key === 'Escape' && moveFileDialog.style.display === 'flex') { + this.closeMoveDialog(); + } + }; + document.addEventListener('keydown', window._moveDialogEscapeHandler); + } + // Copy button handler copyConfirmBtn.addEventListener('click', async () => { // Batch copy mode (from multiSelect) @@ -473,8 +487,14 @@ const contextMenus = { // Initialize dialog navigation state // Start at the parent of the item being moved (so user sees siblings and can navigate) let startFolderId = null; + let startFolderName = null; if (mode === 'file' && item.folder_id) { startFolderId = item.folder_id; + // We need the folder name for breadcrumb - try to get it from current view + const folderEl = document.querySelector(`[data-folder-id="${startFolderId}"]`); + if (folderEl) { + startFolderName = folderEl.querySelector('.folder-name, .item-name')?.textContent || null; + } } else if (mode === 'folder' && item.parent_id) { startFolderId = item.parent_id; } else { @@ -488,7 +508,15 @@ const contextMenus = { window.app.moveDialogItemId = item.id; window.app.moveDialogItemMode = mode; window.app.moveDialogCurrentFolderId = startFolderId; - window.app.moveDialogBreadcrumb = []; + + // Build initial breadcrumb if starting at a non-home folder + // This allows proper navigation back to home + const breadcrumb = []; + if (startFolderId && startFolderId !== window.app.userHomeFolderId && startFolderName) { + // We have the folder name, add it to breadcrumb + breadcrumb.push({ id: startFolderId, name: startFolderName }); + } + window.app.moveDialogBreadcrumb = breadcrumb; // Update dialog title (preserve icon) const dialogHeader = document.getElementById('move-file-dialog').querySelector('.rename-dialog-header'); @@ -562,24 +590,21 @@ const contextMenus = { // Ensure we have the home folder ID before proceeding if (!window.app.userHomeFolderId) { - console.log('[Move Dialog] Home folder ID not set, resolving...'); await window.resolveHomeFolder(); } - // Build URL - use /api/folders/{id}/contents to get CHILDREN of the folder - let url; - let effectiveParentId = parentFolderId || window.app.userHomeFolderId; + // Get the effective folder ID + const effectiveParentId = parentFolderId || window.app.userHomeFolderId; - if (effectiveParentId) { - // Use the contents endpoint to get children (not the folder itself) - url = `/api/folders/${effectiveParentId}/contents`; - } else { - // Last resort fallback: this returns root folders (home folder) - // We should NOT use this as it returns the folder itself, not its contents - console.error('[Move Dialog] No folder ID available, using fallback'); - url = '/api/folders'; + // Must have a folder ID to proceed + if (!effectiveParentId) { + console.error('[Move Dialog] Cannot load folders - no folder ID available'); + return; } + // Use the contents endpoint to get children + const url = `/api/folders/${effectiveParentId}/contents`; + console.log('[Move Dialog] Loading folders from:', url, 'effectiveParentId:', effectiveParentId); const response = await fetch(url, { headers }); if (!response.ok) { @@ -606,13 +631,9 @@ const contextMenus = { const mode = window.app.moveDialogItemMode; const breadcrumb = window.app.moveDialogBreadcrumb || []; - // Only show breadcrumb when we've navigated into subfolders - if (breadcrumb.length > 0) { - this._renderMoveDialogBreadcrumb(breadcrumbContainer, breadcrumb, effectiveParentId); - breadcrumbContainer.style.display = 'flex'; - } else { - breadcrumbContainer.style.display = 'none'; - } + // Always show breadcrumb to allow navigation back to home + this._renderMoveDialogBreadcrumb(breadcrumbContainer, breadcrumb, effectiveParentId); + breadcrumbContainer.style.display = 'flex'; // Option to select current folder as destination (only after navigating into subfolders) if (breadcrumb.length > 0 && effectiveParentId && effectiveParentId !== itemId) { @@ -632,8 +653,9 @@ const contextMenus = { folderSelectContainer.appendChild(currentFolderOption); } - // Add "Go to parent" option if we have navigated into subfolders - if (breadcrumb.length > 0) { + // Add "Go to parent" option if not at home folder + const isAtHomeFolder = effectiveParentId === window.app.userHomeFolderId; + if (!isAtHomeFolder || breadcrumb.length > 0) { const parentOption = document.createElement('div'); parentOption.className = 'folder-select-item folder-navigate-up'; parentOption.innerHTML = ` @@ -654,6 +676,7 @@ const contextMenus = { this.loadMoveDialogFolders(parentFolder ? parentFolder.id : null); } else { // Go to root (home folder) + window.app.moveDialogBreadcrumb = []; window.app.moveDialogCurrentFolderId = window.app.userHomeFolderId || null; this.loadMoveDialogFolders(window.app.userHomeFolderId || null); } @@ -798,63 +821,19 @@ const contextMenus = { }, /** - * Load all folders for the move dialog (legacy - kept for batch operations) - * @param {string} itemId - ID of the item being moved - * @param {string} mode - 'file' or 'folder' + * Load all folders for the move dialog (batch operations) + * Uses the same navigation pattern as loadMoveDialogFolders + * @param {string} itemId - ID of the item being moved (unused, kept for compatibility) + * @param {string} mode - 'batch' for batch operations */ async loadAllFolders(itemId, mode) { - // For batch mode, use the old behavior but start from home folder - try { - const token = localStorage.getItem('oxicloud_token'); - const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; - const response = await fetch('/api/folders', { headers }); - if (response.ok) { - const folders = await response.json(); - const folderSelectContainer = document.getElementById('folder-select-container'); + // For batch mode, use the same navigation as regular move dialog + // Initialize navigation state starting at home folder + window.app.moveDialogBreadcrumb = []; + window.app.moveDialogCurrentFolderId = window.app.userHomeFolderId || null; - // Clear container - folderSelectContainer.innerHTML = ''; - - // Add all available folders - if (Array.isArray(folders)) { - const excludeIds = itemId ? [itemId] : []; - folders.forEach(folder => { - // Skip folders that would create cycles - if (excludeIds.includes(folder.id)) { - return; - } - - const folderItem = document.createElement('div'); - folderItem.className = 'folder-select-item'; - folderItem.dataset.folderId = folder.id; - folderItem.innerHTML = ` ${escapeHtml(folder.name)}`; - - folderItem.addEventListener('click', () => { - // Deselect all - document.querySelectorAll('.folder-select-item').forEach(item => { - item.classList.remove('selected'); - }); - - // Select this one - folderItem.classList.add('selected'); - window.app.selectedTargetFolderId = folder.id; - }); - - folderSelectContainer.appendChild(folderItem); - }); - } - - // Set default selection - window.app.selectedTargetFolderId = ""; - - // Translate new elements (scoped to container) - if (window.i18n && window.i18n.translateElement) { - window.i18n.translateElement(folderSelectContainer); - } - } - } catch (error) { - console.error('Error loading folders:', error); - } + // Use loadMoveDialogFolders which uses /api/folders/{id}/contents + await this.loadMoveDialogFolders(window.app.userHomeFolderId || null); }, /** * Show share dialog for files or folders