diff --git a/Cargo.toml b/Cargo.toml index 594b4b1e..37ec9f26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxicloud" -version = "0.4.0" +version = "0.4.1" edition = "2024" default-run = "oxicloud" diff --git a/src/application/ports/inbound.rs b/src/application/ports/inbound.rs index 2328fc0d..0e972258 100644 --- a/src/application/ports/inbound.rs +++ b/src/application/ports/inbound.rs @@ -55,6 +55,9 @@ pub trait FolderUseCase: Send + Sync + 'static { /// Deletes a folder (ownership verified against caller_id) async fn delete_folder(&self, id: &str, caller_id: &str) -> Result<(), DomainError>; + + /// Creates a root-level home folder for a user during registration. + async fn create_home_folder(&self, user_id: &str, name: String) -> Result; } /** diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 58c2688d..d3908d2a 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -1,4 +1,3 @@ -use crate::application::dtos::folder_dto::CreateFolderDto; use crate::application::dtos::user_dto::{ AuthResponseDto, ChangePasswordDto, LoginDto, RefreshTokenDto, RegisterDto, UserDto, }; @@ -318,43 +317,7 @@ impl AuthApplicationService { let created_user = self.user_storage.create_user(user).await?; // Create personal folder for the user - if let Some(folder_service) = &self.folder_service { - let folder_name = format!("My Folder - {}", dto.username); - - match folder_service - .create_folder(CreateFolderDto { - name: folder_name, - parent_id: None, - }) - .await - { - Ok(folder) => { - tracing::info!( - "Personal folder created for user {}: {} (ID: {})", - created_user.id(), - folder.name, - folder.id - ); - - // Here we could save the folder-to-user association, - // for example, in a folder-user relationship table - } - Err(e) => { - // We don't fail registration due to a folder creation error, - // but we log it for investigation - tracing::error!( - "Could not create personal folder for user {}: {}", - created_user.id(), - e - ); - } - } - } else { - tracing::warn!( - "Folder service not configured, cannot create personal folder for user: {}", - created_user.id() - ); - } + self.create_personal_folder(&dto.username, created_user.id()).await; tracing::info!("User registered: {}", created_user.id()); Ok(UserDto::from(created_user)) @@ -695,34 +658,8 @@ impl AuthApplicationService { // 4. Save the new admin user let created_user = self.user_storage.create_user(user).await?; - // 5. Create personal folder for the new admin if folder service is available - if let Some(folder_service) = &self.folder_service { - let folder_name = format!("My Folder - {}", dto.username); - - match folder_service - .create_folder(CreateFolderDto { - name: folder_name, - parent_id: None, - }) - .await - { - Ok(folder) => { - tracing::info!( - "Personal folder created for admin {}: {} (ID: {})", - created_user.id(), - folder.name, - folder.id - ); - } - Err(e) => { - tracing::error!( - "Could not create personal folder for admin {}: {}", - created_user.id(), - e - ); - } - } - } + // 5. Create personal folder for the new admin + self.create_personal_folder(&dto.username, created_user.id()).await; tracing::info!("Custom admin created: {}", created_user.id()); Ok(UserDto::from(created_user)) @@ -828,32 +765,7 @@ impl AuthApplicationService { } // Create personal folder - if let Some(folder_service) = &self.folder_service { - let folder_name = format!("My Folder - {}", dto.username); - match folder_service - .create_folder(CreateFolderDto { - name: folder_name, - parent_id: None, - }) - .await - { - Ok(folder) => { - tracing::info!( - "Personal folder created for admin-created user {}: {} (ID: {})", - created.id(), - folder.name, - folder.id - ); - } - Err(e) => { - tracing::error!( - "Could not create personal folder for user {}: {}", - created.id(), - e - ); - } - } - } + self.create_personal_folder(&dto.username, created.id()).await; tracing::info!("Admin created user: {} ({})", dto.username, created.id()); Ok(UserDto::from(created)) @@ -1290,10 +1202,7 @@ impl AuthApplicationService { if let Some(folder_service) = &self.folder_service { let folder_name = format!("My Folder - {}", username); match folder_service - .create_folder(CreateFolderDto { - name: folder_name.clone(), - parent_id: None, - }) + .create_home_folder(user_id, folder_name.clone()) .await { Ok(folder) => { diff --git a/src/application/services/folder_service.rs b/src/application/services/folder_service.rs index 978583c5..e4c61da8 100644 --- a/src/application/services/folder_service.rs +++ b/src/application/services/folder_service.rs @@ -111,6 +111,10 @@ impl FolderService { async fn delete_folder(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> { Ok(()) } + + async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result { + Ok(FolderDto::empty()) + } } FolderServiceStub @@ -154,6 +158,22 @@ impl FolderUseCase for FolderService { Ok(FolderDto::from(folder)) } + /// Creates a root-level home folder for a user during registration. + async fn create_home_folder(&self, user_id: &str, name: String) -> Result { + let folder = self + .folder_storage + .create_home_folder(user_id, name) + .await + .map_err(|e| { + DomainError::internal_error( + "FolderStorage", + format!("Failed to create home folder: {}", e), + ) + })?; + + Ok(FolderDto::from(folder)) + } + /// Gets a folder by its ID async fn get_folder(&self, id: &str) -> Result { let folder = self.folder_storage.get_folder(id).await.map_err(|e| { diff --git a/src/application/services/share_service.rs b/src/application/services/share_service.rs index 54f83c3e..d320ccb8 100644 --- a/src/application/services/share_service.rs +++ b/src/application/services/share_service.rs @@ -609,6 +609,14 @@ mod tests { async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> { unimplemented!() } + + async fn create_home_folder( + &self, + _user_id: &str, + _name: String, + ) -> Result { + unimplemented!() + } } struct MockShareRepository { diff --git a/src/application/services/trash_service_test.rs b/src/application/services/trash_service_test.rs index 262e9ef1..0bd86e4b 100644 --- a/src/application/services/trash_service_test.rs +++ b/src/application/services/trash_service_test.rs @@ -461,6 +461,14 @@ impl FolderRepository for MockFolderRepository { )) } } + + async fn create_home_folder( + &self, + _user_id: &str, + _name: String, + ) -> std::result::Result { + Ok(Folder::default()) + } } #[cfg(test)] diff --git a/src/common/stubs.rs b/src/common/stubs.rs index d394bf4a..4011150e 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -322,6 +322,14 @@ impl FolderRepository for StubFolderStoragePort { async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> { Ok(()) } + + async fn create_home_folder( + &self, + _user_id: &str, + _name: String, + ) -> Result { + Ok(Folder::default()) + } } // --------------------------------------------------------------------------- @@ -414,6 +422,10 @@ impl FolderUseCase for StubFolderUseCase { async fn delete_folder(&self, _id: &str, _caller_id: &str) -> Result<(), DomainError> { Ok(()) } + + async fn create_home_folder(&self, _user_id: &str, _name: String) -> Result { + Ok(FolderDto::default()) + } } // --------------------------------------------------------------------------- diff --git a/src/domain/repositories/folder_repository.rs b/src/domain/repositories/folder_repository.rs index b7fba4ce..ff6e99e3 100644 --- a/src/domain/repositories/folder_repository.rs +++ b/src/domain/repositories/folder_repository.rs @@ -99,4 +99,12 @@ pub trait FolderRepository: Send + Sync + 'static { /// Permanently deletes a folder (used by the trash) async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError>; + + /// Creates a root-level home folder for a user. + /// This is used during user registration to create the user's personal folder. + async fn create_home_folder( + &self, + user_id: &str, + name: String, + ) -> Result; } diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index dd50778a..eea07a31 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -603,17 +603,11 @@ impl FolderRepository for FolderDbRepository { } Ok(()) } -} -// ── Extra helpers for blob-storage bootstrap ── - -impl FolderDbRepository { - /// Creates a root-level home folder for a user. - /// This is called during user registration. - pub async fn create_home_folder( + async fn create_home_folder( &self, user_id: &str, - name: &str, + name: String, ) -> Result { let row = sqlx::query_as::<_, (String, String, i64, i64)>( r#" @@ -626,14 +620,14 @@ impl FolderDbRepository { EXTRACT(EPOCH FROM updated_at)::bigint "#, ) - .bind(name) + .bind(&name) .bind(user_id) .fetch_optional(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; match row { - Some((id, path, ca, ma)) => Self::row_to_folder(id, name.to_string(), path, None, Some(user_id.to_string()), ca, ma), + Some((id, path, ca, ma)) => Self::row_to_folder(id, name.clone(), path, None, Some(user_id.to_string()), ca, ma), None => { // Already exists — fetch it let existing = sqlx::query_as::<_, (String, String, i64, i64)>( @@ -646,16 +640,20 @@ impl FolderDbRepository { WHERE name = $1 AND user_id = $2 AND parent_id IS NULL "#, ) - .bind(name) + .bind(&name) .bind(user_id) .fetch_one(self.pool()) .await .map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?; - Self::row_to_folder(existing.0, name.to_string(), existing.1, None, Some(user_id.to_string()), existing.2, existing.3) + Self::row_to_folder(existing.0, name, existing.1, None, Some(user_id.to_string()), existing.2, existing.3) } } } +} +// ── Extra helpers for blob-storage bootstrap ── + +impl FolderDbRepository { /// Returns user_id for a given folder. Used by file repositories. pub async fn get_folder_user_id(&self, folder_id: &str) -> Result { sqlx::query_scalar::<_, String>("SELECT user_id FROM storage.folders WHERE id = $1::uuid") diff --git a/static/css/style.css b/static/css/style.css index 50a64d1a..0d55aa70 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2498,6 +2498,225 @@ select:focus { padding: 0; } +/* Shared View Dialog (edit/notification dialogs inside sharedView component) */ +.shared-dialog { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.45); + display: none; + justify-content: center; + align-items: center; + z-index: 3000; + backdrop-filter: blur(2px); + animation: modalFadeIn 0.2s ease; +} + +.shared-dialog.active { + display: flex; +} + +.shared-dialog-content { + background-color: white; + border-radius: 16px; + width: 480px; + max-width: 90%; + box-shadow: 0 20px 60px rgba(0,0,0,0.25); + overflow: hidden; + animation: modalSlideIn 0.25s ease; + max-height: 85vh; + overflow-y: auto; + padding: 0; +} + +.shared-dialog-header { + font-size: 17px; + font-weight: 600; + color: #1a202c; + padding: 20px 24px; + border-bottom: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 12px; +} + +.shared-dialog-header .close-dialog-btn { + margin-left: auto; + background: none; + border: none; + font-size: 22px; + cursor: pointer; + color: #718096; + padding: 4px 8px; + border-radius: 6px; + transition: all 0.15s; +} + +.shared-dialog-header .close-dialog-btn:hover { + background: #f0f0f0; + color: #1a202c; +} + +.shared-dialog .share-link-section, +.shared-dialog .share-permissions-section, +.shared-dialog .share-password-section, +.shared-dialog .share-expiration-section, +.shared-dialog .notification-form { + padding: 16px 24px; +} + +.shared-dialog .share-link-section label, +.shared-dialog .share-permissions-section h4, +.shared-dialog .notification-form label { + display: block; + font-weight: 600; + margin-bottom: 8px; + color: #2d3748; + font-size: 14px; +} + +.shared-dialog .share-link-input { + display: flex; + gap: 8px; +} + +.shared-dialog .share-link-input input { + flex: 1; + padding: 8px 12px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + background: #f8fafc; + color: #1a202c; +} + +.shared-dialog .share-permissions-section label, +.shared-dialog .share-password-section label, +.shared-dialog .share-expiration-section label { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 14px; + color: #4a5568; +} + +.shared-dialog .password-input-group { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.shared-dialog .password-input-group input { + flex: 1; + padding: 8px 12px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + background: #f8fafc; +} + +.shared-dialog .share-expiration-section input[type="date"] { + padding: 8px 12px; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 13px; + background: #f8fafc; + margin-top: 8px; +} + +.shared-dialog .share-actions, +.shared-dialog .notification-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; +} + +.shared-dialog .notification-form input, +.shared-dialog .notification-form textarea { + width: 100%; + padding: 10px 14px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 14px; + background: #f8fafc; + color: #1a202c; + outline: none; + transition: all 0.15s ease; + box-sizing: border-box; +} + +.shared-dialog .notification-form input:focus, +.shared-dialog .notification-form textarea:focus { + border-color: #ff5e3a; + background: white; + box-shadow: 0 0 0 3px rgba(255,94,58,0.1); +} + +.shared-dialog .notification-form .form-group { + padding: 0; + margin-bottom: 15px; +} + +/* Dark mode for shared-dialog */ +[data-theme="dark"] .shared-dialog-content { + background-color: #1e293b; +} + +[data-theme="dark"] .shared-dialog-header { + color: #e2e8f0; + border-bottom-color: #334155; +} + +[data-theme="dark"] .shared-dialog-header .close-dialog-btn { + color: #94a3b8; +} + +[data-theme="dark"] .shared-dialog-header .close-dialog-btn:hover { + background: #334155; + color: #e2e8f0; +} + +[data-theme="dark"] .shared-dialog .share-link-input input, +[data-theme="dark"] .shared-dialog .password-input-group input, +[data-theme="dark"] .shared-dialog .share-expiration-section input, +[data-theme="dark"] .shared-dialog .notification-form input, +[data-theme="dark"] .shared-dialog .notification-form textarea { + background: #0f172a; + border-color: #334155; + color: #e2e8f0; +} + +[data-theme="dark"] .shared-dialog .share-link-input input:focus, +[data-theme="dark"] .shared-dialog .notification-form input:focus, +[data-theme="dark"] .shared-dialog .notification-form textarea:focus { + border-color: #ff5e3a; + background: #1e293b; +} + +[data-theme="dark"] .shared-dialog .share-link-section label, +[data-theme="dark"] .shared-dialog .share-permissions-section h4, +[data-theme="dark"] .shared-dialog .notification-form label { + color: #e2e8f0; +} + +[data-theme="dark"] .shared-dialog .share-permissions-section label, +[data-theme="dark"] .shared-dialog .share-password-section label, +[data-theme="dark"] .shared-dialog .share-expiration-section label { + color: #94a3b8; +} + +[data-theme="dark"] .shared-dialog .share-actions, +[data-theme="dark"] .shared-dialog .notification-actions { + background: #162032; + border-top-color: #334155; +} + /* Folder select items in move dialog */ .folder-select-item { display: flex; @@ -4535,6 +4754,8 @@ html[dir='rtl'] .fa-sign-out-alt { /* Empty state */ [data-theme="dark"] .empty-state { + background-color: #1e293b; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); color: #64748b; } [data-theme="dark"] .empty-state i { @@ -4633,7 +4854,8 @@ html[dir='rtl'] .fa-sign-out-alt { /* ── Shared View Dark Mode ── */ [data-theme="dark"] .shared-filters { - background-color: transparent; + background-color: #1e293b; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); } [data-theme="dark"] .filter-group label { color: #94a3b8; @@ -4776,4 +4998,31 @@ html[dir='rtl'] .fa-sign-out-alt { } [data-theme="dark"] .search-sort-select:focus { border-color: #ff5e3a; +} + +/* Trash & action buttons dark mode */ +[data-theme="dark"] .trash-actions button, +[data-theme="dark"] .actions-cell button { + background: #1e293b; + border-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .trash-actions button:hover, +[data-theme="dark"] .actions-cell button:hover { + background: #334155; +} +[data-theme="dark"] .action-btn { + background-color: #1e293b; + border-color: #334155; + color: #e2e8f0; +} +[data-theme="dark"] .action-btn:hover { + background-color: #334155; + border-color: #475569; +} + +/* Header dark mode (shared page) */ +[data-theme="dark"] header { + background-color: #1e293b; + border-bottom-color: #334155; } \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js index 807f6161..2b63e495 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -543,6 +543,7 @@ function setupEventListeners() { // Update UI elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.trash') : 'Trash'; + elements.pageTitle.setAttribute('data-i18n', 'nav.trash'); elements.actionsBar.innerHTML = `
@@ -89,8 +88,8 @@ const sharedView = { @@ -98,78 +97,78 @@ const sharedView = { - - - - - - - + + + + + + +
NameTypeDateExpirationPermissionsPasswordActionsNameTypeDateExpirationPermissionsPasswordActions
- -
+ +
- 📄 - Item + 📄 + Item
- -
+ +
- 📧 - Item + 📧 + Item
- +
- +
- +
@@ -200,37 +199,37 @@ const sharedView = { if (searchBtn) searchBtn.addEventListener('click', () => this.filterAndSortItems()); if (emptyGoToFiles) emptyGoToFiles.addEventListener('click', () => window.switchToFilesView()); - // Share dialog - const shareDialog = document.getElementById('share-dialog'); + // Share dialog (sharedView-specific IDs) + const shareDialog = document.getElementById('shared-view-edit-dialog'); if (shareDialog) { const closeBtn = shareDialog.querySelector('.close-dialog-btn'); if (closeBtn) closeBtn.addEventListener('click', () => this.closeShareDialog()); - const copyLinkBtn = document.getElementById('copy-link-btn'); + const copyLinkBtn = document.getElementById('sv-copy-link-btn'); if (copyLinkBtn) copyLinkBtn.addEventListener('click', () => this.copyShareLink()); - const enablePw = document.getElementById('enable-password'); - const pwField = document.getElementById('share-password'); + const enablePw = document.getElementById('sv-enable-password'); + const pwField = document.getElementById('sv-share-password'); if (enablePw) enablePw.addEventListener('change', () => { if (pwField) { pwField.disabled = !enablePw.checked; if (enablePw.checked) pwField.focus(); } }); - const genPwBtn = document.getElementById('generate-password'); + const genPwBtn = document.getElementById('sv-generate-password'); if (genPwBtn) genPwBtn.addEventListener('click', () => this.generatePassword()); - const enableExp = document.getElementById('enable-expiration'); - const expField = document.getElementById('share-expiration'); + const enableExp = document.getElementById('sv-enable-expiration'); + const expField = document.getElementById('sv-share-expiration'); if (enableExp) enableExp.addEventListener('change', () => { if (expField) { expField.disabled = !enableExp.checked; if (enableExp.checked) expField.focus(); } }); - const updateBtn = document.getElementById('update-share-btn'); + const updateBtn = document.getElementById('sv-update-share-btn'); if (updateBtn) updateBtn.addEventListener('click', () => this.updateSharedItem()); - const removeBtn = document.getElementById('remove-share-btn'); + const removeBtn = document.getElementById('sv-remove-share-btn'); if (removeBtn) removeBtn.addEventListener('click', () => this.removeSharedItem()); } - // Notification dialog - const notifDialog = document.getElementById('share-notification-dialog'); + // Notification dialog (sharedView-specific IDs) + const notifDialog = document.getElementById('sv-notification-dialog'); if (notifDialog) { const closeBtn = notifDialog.querySelector('.close-dialog-btn'); if (closeBtn) closeBtn.addEventListener('click', () => this.closeNotificationDialog()); - const sendBtn = document.getElementById('send-notification-btn'); + const sendBtn = document.getElementById('sv-send-notification-btn'); if (sendBtn) sendBtn.addEventListener('click', () => this.sendNotification()); } }, @@ -359,19 +358,19 @@ const sharedView = { // Open share dialog openShareDialog(item) { this.currentItem = item; - const shareDialog = document.getElementById('share-dialog'); + const shareDialog = document.getElementById('shared-view-edit-dialog'); const dn = item.item_name || item.item_id || 'Unknown'; - const iconEl = document.getElementById('share-dialog-icon'); - const nameEl = document.getElementById('share-dialog-name'); - const urlEl = document.getElementById('share-link-url'); - const enablePw = document.getElementById('enable-password'); - const pwField = document.getElementById('share-password'); - const enableExp = document.getElementById('enable-expiration'); - const expField = document.getElementById('share-expiration'); - const permRead = document.getElementById('permission-read'); - const permWrite = document.getElementById('permission-write'); - const permReshare = document.getElementById('permission-reshare'); + const iconEl = document.getElementById('sv-dialog-icon'); + const nameEl = document.getElementById('sv-dialog-name'); + const urlEl = document.getElementById('sv-share-link-url'); + const enablePw = document.getElementById('sv-enable-password'); + const pwField = document.getElementById('sv-share-password'); + const enableExp = document.getElementById('sv-enable-expiration'); + const expField = document.getElementById('sv-share-expiration'); + const permRead = document.getElementById('sv-permission-read'); + const permWrite = document.getElementById('sv-permission-write'); + const permReshare = document.getElementById('sv-permission-reshare'); if (!shareDialog) return; if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; @@ -398,7 +397,7 @@ const sharedView = { }, closeShareDialog() { - const d = document.getElementById('share-dialog'); + const d = document.getElementById('shared-view-edit-dialog'); if (d) d.classList.remove('active'); this.currentItem = null; }, @@ -406,11 +405,11 @@ const sharedView = { openNotificationDialog(item) { this.currentItem = item; const dn = item.item_name || item.item_id || 'Unknown'; - const d = document.getElementById('share-notification-dialog'); - const iconEl = document.getElementById('notify-dialog-icon'); - const nameEl = document.getElementById('notify-dialog-name'); - const emailEl = document.getElementById('notification-email'); - const msgEl = document.getElementById('notification-message'); + const d = document.getElementById('sv-notification-dialog'); + const iconEl = document.getElementById('sv-notify-dialog-icon'); + const nameEl = document.getElementById('sv-notify-dialog-name'); + const emailEl = document.getElementById('sv-notification-email'); + const msgEl = document.getElementById('sv-notification-message'); if (!d) return; if (iconEl) iconEl.textContent = item.item_type === 'file' ? '📄' : '📁'; @@ -421,13 +420,13 @@ const sharedView = { }, closeNotificationDialog() { - const d = document.getElementById('share-notification-dialog'); + const d = document.getElementById('sv-notification-dialog'); if (d) d.classList.remove('active'); this.currentItem = null; }, copyShareLink() { - const el = document.getElementById('share-link-url'); + const el = document.getElementById('sv-share-link-url'); if (!el) return; navigator.clipboard.writeText(el.value) .then(() => this.showNotification(this.translate('shared_linkCopied', 'Link copied!'))) @@ -436,8 +435,8 @@ const sharedView = { // Generate secure password with crypto API generatePassword() { - const pwField = document.getElementById('share-password'); - const enablePw = document.getElementById('enable-password'); + const pwField = document.getElementById('sv-share-password'); + const enablePw = document.getElementById('sv-enable-password'); if (!pwField || !enablePw) return; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; @@ -456,13 +455,13 @@ const sharedView = { async updateSharedItem() { if (!this.currentItem) return; - const permRead = document.getElementById('permission-read'); - const permWrite = document.getElementById('permission-write'); - const permReshare = document.getElementById('permission-reshare'); - const enablePw = document.getElementById('enable-password'); - const pwField = document.getElementById('share-password'); - const enableExp = document.getElementById('enable-expiration'); - const expField = document.getElementById('share-expiration'); + const permRead = document.getElementById('sv-permission-read'); + const permWrite = document.getElementById('sv-permission-write'); + const permReshare = document.getElementById('sv-permission-reshare'); + const enablePw = document.getElementById('sv-enable-password'); + const pwField = document.getElementById('sv-share-password'); + const enableExp = document.getElementById('sv-enable-expiration'); + const expField = document.getElementById('sv-share-expiration'); const body = { permissions: { @@ -521,8 +520,8 @@ const sharedView = { // Send notification (stub) sendNotification() { if (!this.currentItem) return; - const emailEl = document.getElementById('notification-email'); - const msgEl = document.getElementById('notification-message'); + const emailEl = document.getElementById('sv-notification-email'); + const msgEl = document.getElementById('sv-notification-message'); const email = emailEl ? emailEl.value.trim() : ''; const message = msgEl ? msgEl.value.trim() : ''; diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index 49f07a3d..189c82b7 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -23,6 +23,7 @@ const fileOps = { // Upload progress — notification bell integration // ======================================================================== _currentBatchId: null, + _isUploading: false, // Guard against concurrent upload calls /** Start a new upload batch in the notification bell */ _initUploadToast(totalFiles) { @@ -103,86 +104,97 @@ const fileOps = { const totalFiles = files.length; if (totalFiles === 0) return; - // Legacy progress bar (inside dropzone) — keep working for drag-drop - const progressBar = document.querySelector('.progress-fill'); - const uploadProgressDiv = document.querySelector('.upload-progress'); - if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; } - if (progressBar) { progressBar.style.width = '0%'; } - - // Show upload notification - this._initUploadToast(totalFiles); - const batchId = this._currentBatchId; - - let uploadedCount = 0; - let successCount = 0; - - for (let i = 0; i < totalFiles; i++) { - const file = files[i]; - const formData = new FormData(); - - const targetFolderId = window.app.currentPath || window.app.userHomeFolderId; - if (targetFolderId) formData.append('folder_id', targetFolderId); - formData.append('file', file); - - console.log(`Uploading file to folder: ${targetFolderId || 'root'}`, { - file: file.name, size: file.size - }); - - const result = await this._uploadFileXHR(formData, batchId, file.name); - - uploadedCount++; - - // Legacy dropzone bar - if (progressBar) { - progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%'; - } - // Notify bell of per-file completion - if (window.notifications && batchId) { - window.notifications.fileCompleted(batchId, result.ok); - } - - if (result.ok) { - successCount++; - console.log(`Successfully uploaded ${file.name}`, result.data); - } else { - console.error(`Upload error for ${file.name}`); - if (result.isQuotaError) { - const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded'; - if (window.notifications) { - window.notifications.addNotification({ - icon: 'fa-exclamation-triangle', - iconClass: 'error', - title: file.name, - text: msg - }); - } - break; - } - } - } - - // All done - this._finishUploadToast(successCount, totalFiles); - - // Wait for backend to persist, then reload - await new Promise(resolve => setTimeout(resolve, 800)); - - // Refresh storage usage display - if (typeof window.refreshUserData === 'function') { - try { await window.refreshUserData(); } catch (_) {} + // Guard: prevent concurrent upload calls (e.g. double drop events) + if (this._isUploading) { + console.warn('Upload already in progress, ignoring duplicate call'); + return; } + this._isUploading = true; try { - await window.loadFiles({ forceRefresh: true }); - } catch (reloadError) { - console.error('Error reloading files:', reloadError); - } + // Legacy progress bar (inside dropzone) — keep working for drag-drop + const progressBar = document.querySelector('.progress-fill'); + const uploadProgressDiv = document.querySelector('.upload-progress'); + if (uploadProgressDiv) { uploadProgressDiv.style.display = 'block'; } + if (progressBar) { progressBar.style.width = '0%'; } - setTimeout(() => { - const dropzone = document.getElementById('dropzone'); - if (dropzone) dropzone.style.display = 'none'; - if (uploadProgressDiv) uploadProgressDiv.style.display = 'none'; - }, 500); + // Show upload notification + this._initUploadToast(totalFiles); + const batchId = this._currentBatchId; + + let uploadedCount = 0; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = files[i]; + const formData = new FormData(); + + const targetFolderId = window.app.currentPath || window.app.userHomeFolderId; + if (targetFolderId) formData.append('folder_id', targetFolderId); + formData.append('file', file); + + console.log(`Uploading file to folder: ${targetFolderId || 'root'}`, { + file: file.name, size: file.size + }); + + const result = await this._uploadFileXHR(formData, batchId, file.name); + + uploadedCount++; + + // Legacy dropzone bar + if (progressBar) { + progressBar.style.width = ((uploadedCount / totalFiles) * 100) + '%'; + } + // Notify bell of per-file completion + if (window.notifications && batchId) { + window.notifications.fileCompleted(batchId, result.ok); + } + + if (result.ok) { + successCount++; + console.log(`Successfully uploaded ${file.name}`, result.data); + } else { + console.error(`Upload error for ${file.name}`); + if (result.isQuotaError) { + const msg = result.errorMsg || window.i18n?.t('storage_quota_exceeded') || 'Storage quota exceeded'; + if (window.notifications) { + window.notifications.addNotification({ + icon: 'fa-exclamation-triangle', + iconClass: 'error', + title: file.name, + text: msg + }); + } + break; + } + } + } + + // All done + this._finishUploadToast(successCount, totalFiles); + + // Wait for backend to persist, then reload + await new Promise(resolve => setTimeout(resolve, 800)); + + // Refresh storage usage display + if (typeof window.refreshUserData === 'function') { + try { await window.refreshUserData(); } catch (_) {} + } + + try { + await window.loadFiles({ forceRefresh: true }); + } catch (reloadError) { + console.error('Error reloading files:', reloadError); + } + + setTimeout(() => { + const dropzone = document.getElementById('dropzone'); + if (dropzone) dropzone.style.display = 'none'; + if (uploadProgressDiv) uploadProgressDiv.style.display = 'none'; + }, 500); + } finally { + this._isUploading = false; + } }, /** diff --git a/static/js/ui.js b/static/js/ui.js index 2eb8a647..487a53c7 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -287,10 +287,15 @@ const ui = { dropzone.addEventListener('drop', (e) => { e.preventDefault(); + e.stopPropagation(); // Prevent bubbling to document's drop handler (avoids double upload) + e._oxiHandled = true; // Mark as handled for document-level fallback dropzone.classList.remove('active'); if (e.dataTransfer.files.length > 0) { fileOps.uploadFiles(e.dataTransfer.files); } + setTimeout(() => { + dropzone.style.display = 'none'; + }, 500); }); // Document-wide drag and drop @@ -318,6 +323,9 @@ const ui = { e.preventDefault(); dropzone.classList.remove('active'); + // Skip if already handled by the dropzone handler (defensive against bubble leaks) + if (e._oxiHandled) return; + if (e.dataTransfer.files.length > 0) { fileOps.uploadFiles(e.dataTransfer.files); }