diff --git a/Cargo.toml b/Cargo.toml index 4e83bb9d..f81a85a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "oxicloud" -version = "0.1.0" +version = "0.3.0" edition = "2021" diff --git a/doc/images/Captura de pantalla 2025-03-23 230739.png b/doc/images/Captura de pantalla 2025-03-23 230739.png index 63d60963..d36b45c3 100644 Binary files a/doc/images/Captura de pantalla 2025-03-23 230739.png and b/doc/images/Captura de pantalla 2025-03-23 230739.png differ diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 2f6f6899..08efe106 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -125,6 +125,9 @@ pub trait FileManagementUseCase: Send + Sync + 'static { /// Mueve un archivo a otra carpeta async fn move_file(&self, file_id: &str, folder_id: Option) -> Result; + /// Renombra un archivo + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; + /// Elimina un archivo async fn delete_file(&self, id: &str) -> Result<(), DomainError>; diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index ca679015..2d8cc1c3 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -91,6 +91,13 @@ pub trait FileWritePort: Send + Sync + 'static { target_folder_id: Option, ) -> Result; + /// Renombra un archivo (same folder, different name). + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + ) -> Result; + /// Elimina un archivo. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 8afdfa2f..4eb5ba55 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -109,6 +109,27 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(moved_file)) } + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + ) -> Result { + info!("Renaming file with ID: {} to \"{}\"", file_id, new_name); + + let renamed_file = self.file_repository.rename_file(file_id, new_name).await.map_err(|e| { + error!("Error renaming file (ID: {}): {}", file_id, e); + e + })?; + + info!( + "File renamed successfully: {} (ID: {})", + renamed_file.name(), + renamed_file.id() + ); + + Ok(FileDto::from(renamed_file)) + } + async fn delete_file(&self, id: &str) -> Result<(), DomainError> { self.file_repository.delete_file(id).await } diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 6a8d7cd2..2d4eb5af 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -314,6 +314,14 @@ impl FileWritePort for StubFileWritePort { Ok(File::default()) } + async fn rename_file( + &self, + _file_id: &str, + _new_name: &str, + ) -> Result { + Ok(File::default()) + } + async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { Ok(()) } @@ -640,6 +648,14 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } + async fn rename_file( + &self, + _file_id: &str, + _new_name: &str, + ) -> Result { + Ok(FileDto::default()) + } + async fn delete_file(&self, _id: &str) -> Result<(), DomainError> { Ok(()) } diff --git a/src/domain/repositories/file_repository.rs b/src/domain/repositories/file_repository.rs index bcb7ef24..6e4508e5 100644 --- a/src/domain/repositories/file_repository.rs +++ b/src/domain/repositories/file_repository.rs @@ -97,6 +97,13 @@ pub trait FileWriteRepository: Send + Sync + 'static { target_folder_id: Option, ) -> Result; + /// Renombra un archivo (same folder, different name). + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + ) -> Result; + /// Elimina un archivo. async fn delete_file(&self, id: &str) -> Result<(), DomainError>; diff --git a/src/infrastructure/repositories/composite_file_repository.rs b/src/infrastructure/repositories/composite_file_repository.rs index a23a2c4f..a19ab957 100644 --- a/src/infrastructure/repositories/composite_file_repository.rs +++ b/src/infrastructure/repositories/composite_file_repository.rs @@ -107,6 +107,14 @@ impl FileWritePort for CompositeFileRepository { self.write.move_file(file_id, target_folder_id).await } + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + ) -> Result { + self.write.rename_file(file_id, new_name).await + } + async fn delete_file(&self, id: &str) -> Result<(), DomainError> { self.write.delete_file(id).await } diff --git a/src/infrastructure/repositories/file_fs_write_repository.rs b/src/infrastructure/repositories/file_fs_write_repository.rs index 72b44bed..1e8617f8 100644 --- a/src/infrastructure/repositories/file_fs_write_repository.rs +++ b/src/infrastructure/repositories/file_fs_write_repository.rs @@ -372,6 +372,55 @@ impl FileWritePort for FileFsWriteRepository { .map_err(|e| DomainError::internal_error("File", e.to_string())) } + async fn rename_file( + &self, + file_id: &str, + new_name: &str, + ) -> Result { + // 1. Get current file info + let original_path = self.id_mapping_service.get_path_by_id(file_id).await?; + let old_abs = self.resolve_storage_path(&original_path); + if !old_abs.exists() || !old_abs.is_file() { + return Err(DomainError::not_found("File", file_id.to_string())); + } + let (size, created_at, modified_at) = self.get_file_metadata_raw(&old_abs).await.map_err(map_repo_err)?; + + // 2. Build new path (same parent directory, different filename) + let parent = original_path.parent() + .unwrap_or_else(|| StoragePath::new(vec![])); + let new_storage_path = parent.join(new_name); + if self.file_exists_at_storage_path(&new_storage_path).await.map_err(map_repo_err)? { + return Err(DomainError::already_exists("File", + format!("File already exists: {}", new_name))); + } + let new_abs = self.resolve_storage_path(&new_storage_path); + let mime = from_path(&new_abs).first_or_octet_stream().to_string(); + + // 3. Rename on disk + time::timeout( + self.config.timeouts.file_timeout(), + FileSystemUtils::rename_with_sync(&old_abs, &new_abs), + ).await + .map_err(|_| DomainError::internal_error("File", "Timeout renaming file"))? + .map_err(|e| DomainError::internal_error("File", e.to_string()))?; + + // 4. Update id→path mapping + self.id_mapping_service.update_path(file_id, &new_storage_path).await?; + let _ = self.id_mapping_service.save_changes().await; + + File::with_timestamps( + file_id.to_string(), + new_name.to_string(), + new_storage_path, + size, + mime, + None, + created_at, + modified_at, + ) + .map_err(|e| DomainError::internal_error("File", e.to_string())) + } + async fn delete_file(&self, id: &str) -> Result<(), DomainError> { let storage_path = self.id_mapping_service.get_path_by_id(id).await?; let abs_path = self.resolve_storage_path(&storage_path); diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index fb059b28..bf254735 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -540,6 +540,41 @@ impl FileHandler { // MOVE // ═══════════════════════════════════════════════════════════════════════ + /// Renames a file + pub async fn rename_file( + State(state): State, + Path(id): Path, + Json(payload): Json, + ) -> impl IntoResponse { + let new_name = match payload.get("name").and_then(|v| v.as_str()) { + Some(name) if !name.trim().is_empty() => name.trim().to_string(), + _ => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + "error": "Missing or empty 'name' field" + }))).into_response(); + } + }; + + tracing::info!("Renaming file {} to \"{}\"", id, new_name); + let mgmt = &state.applications.file_management_service; + match mgmt.rename_file(&id, &new_name).await { + Ok(file_dto) => (StatusCode::OK, Json(file_dto)).into_response(), + Err(err) => { + tracing::error!("Error renaming file: {}", err); + let status = if err.to_string().contains("not found") || err.to_string().contains("NotFound") { + StatusCode::NOT_FOUND + } else if err.to_string().contains("already exists") { + StatusCode::CONFLICT + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + (status, Json(serde_json::json!({ + "error": format!("Error renaming file: {}", err) + }))).into_response() + } + } + } + /// Moves a file to a different folder pub async fn move_file( State(state): State, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 10d7eb03..b97122dc 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -2,13 +2,23 @@ use std::sync::Arc; use axum::{ routing::{get, post, put, delete}, Router, + response::Json as AxumJson, }; +use serde_json::json; use tower_http::{ compression::CompressionLayer, trace::TraceLayer, }; use crate::common::di::AppState; +/// Returns the application version from Cargo.toml (compile-time constant) +async fn get_version() -> AxumJson { + AxumJson(json!({ + "name": "OxiCloud", + "version": env!("CARGO_PKG_VERSION") + })) +} + use crate::interfaces::middleware::cache::{HttpCache, start_cache_cleanup_task}; use crate::application::services::batch_operations::BatchOperationService; @@ -57,6 +67,9 @@ pub fn create_public_api_routes(app_state: &AppState) -> Router { router = router.nest("/i18n", i18n_router); } + // Version endpoint — public, no auth required + router = router.route("/version", get(get_version)); + router } @@ -134,7 +147,8 @@ pub fn create_api_routes(app_state: &AppState) -> Router { // File operations with trash support let file_operations_router = Router::new() .route("/{id}", delete(FileHandler::delete_file)) - .route("/{id}/move", put(FileHandler::move_file_simple)); + .route("/{id}/move", put(FileHandler::move_file_simple)) + .route("/{id}/rename", put(FileHandler::rename_file)); // Merge the routers let files_router = basic_file_router.merge(file_operations_router); diff --git a/static/css/auth.css b/static/css/auth.css index 5c2ff2ce..5a41219c 100644 --- a/static/css/auth.css +++ b/static/css/auth.css @@ -30,12 +30,13 @@ .auth-logo-icon { width: 50px; height: 50px; - background-color: #ff5e3a; - border-radius: 50%; + background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%); + border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-right: 10px; + box-shadow: 0 4px 12px rgba(255, 94, 58, 0.3); } .auth-logo-icon svg { @@ -97,19 +98,27 @@ .auth-button { width: 100%; padding: 12px 15px; - border-radius: 8px; - background-color: #ff5e3a; + border-radius: 10px; + background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%); color: white; font-weight: bold; border: none; cursor: pointer; font-size: 16px; - transition: background-color 0.2s; + transition: all 0.3s ease; margin-top: 10px; + box-shadow: 0 4px 12px rgba(255, 94, 58, 0.3); } .auth-button:hover { - background-color: #e64a2e; + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(255, 94, 58, 0.4); + filter: brightness(1.05); +} + +.auth-button:active { + transform: translateY(0); + box-shadow: 0 2px 8px rgba(255, 94, 58, 0.3); } .auth-button:disabled { @@ -205,6 +214,26 @@ text-align: center; } +/* Auto-detected language banner */ +.lang-autodetected { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: linear-gradient(135deg, #f0fdf4, #ecfdf5); + border: 1px solid #bbf7d0; + border-radius: 8px; + padding: 10px 16px; + margin-bottom: 16px; + color: #16a34a; + font-size: 14px; + font-weight: 500; +} + +.lang-autodetected i { + font-size: 14px; +} + .language-subtitle { color: #64748b; font-size: 16px; @@ -290,6 +319,183 @@ margin-left: auto; } +/* "More languages" button */ +.lang-more-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 12px; + margin-bottom: 20px; + border: 1px dashed #cbd5e1; + border-radius: 10px; + background: transparent; + color: #64748b; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.lang-more-btn:hover { + border-color: #ff5e3a; + color: #ff5e3a; + background: #fff5f3; +} + +.lang-more-btn i { + font-size: 16px; +} + +/* ====== Language Modal ====== */ +.lang-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 20px; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.lang-modal { + background: #fff; + border-radius: 16px; + width: 100%; + max-width: 480px; + max-height: 70vh; + display: flex; + flex-direction: column; + box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25); + animation: slideUp 0.25s ease; +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.lang-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px 12px; +} + +.lang-modal-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #1e293b; +} + +.lang-modal-close { + background: none; + border: none; + font-size: 24px; + color: #94a3b8; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + line-height: 1; + transition: all 0.15s; +} + +.lang-modal-close:hover { + background: #f1f5f9; + color: #475569; +} + +.lang-modal-search { + position: relative; + padding: 0 24px 12px; +} + +.lang-modal-search i { + position: absolute; + left: 38px; + top: 12px; + color: #94a3b8; + font-size: 14px; +} + +.lang-modal-search input { + width: 100%; + padding: 10px 14px 10px 36px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 14px; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.lang-modal-search input:focus { + border-color: #ff5e3a; +} + +.lang-modal-list { + overflow-y: auto; + padding: 0 12px 16px; + flex: 1; +} + +.lang-modal-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 10px; + cursor: pointer; + transition: all 0.15s; +} + +.lang-modal-item:hover { + background: #f8fafc; +} + +.lang-modal-item.selected { + background: #fff5f3; +} + +.lang-modal-flag { + font-size: 24px; + flex-shrink: 0; +} + +.lang-modal-native { + font-size: 15px; + font-weight: 500; + color: #1e293b; +} + +.lang-modal-english { + font-size: 13px; + color: #94a3b8; + margin-left: auto; +} + +.lang-modal-check { + color: #ff5e3a; + font-size: 14px; + flex-shrink: 0; +} + +.lang-modal-empty { + text-align: center; + color: #94a3b8; + padding: 30px; + font-size: 14px; +} + @media (max-width: 480px) { .auth-panel { width: 90%; @@ -303,4 +509,13 @@ .language-flag { font-size: 24px; } + + .lang-modal { + max-height: 80vh; + } + + .lang-autodetected { + font-size: 13px; + padding: 8px 12px; + } } diff --git a/static/css/style.css b/static/css/style.css index af6bad6f..f8cb08e9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -69,118 +69,175 @@ select:focus { /* Sidebar */ .sidebar { width: 250px; - background-color: #2a3042; + background: linear-gradient(180deg, #2a3042 0%, #232838 100%); color: #fff; display: flex; flex-direction: column; height: 100%; flex-shrink: 0; + box-shadow: 2px 0 12px rgba(0,0,0,0.15); } .logo-container { - padding: 20px; + padding: 22px 20px; display: flex; align-items: center; - margin-bottom: 20px; + border-bottom: 1px solid rgba(255,255,255,0.07); + margin-bottom: 8px; } .logo { width: 40px; height: 40px; - background-color: #ff5e3a; - border-radius: 50%; + background: linear-gradient(135deg, #ff5e3a 0%, #ff8a5c 100%); + border-radius: 12px; display: flex; align-items: center; justify-content: center; - margin-right: 10px; + margin-right: 12px; + box-shadow: 0 3px 10px rgba(255,94,58,0.35); + transition: transform 0.2s, box-shadow 0.2s; [dir='rtl'] & { - margin-left: 10px; + margin-left: 12px; margin-right: unset; } } +.logo:hover { + transform: scale(1.05); + box-shadow: 0 4px 14px rgba(255,94,58,0.45); +} + .logo svg { - width: 24px; - height: 24px; + width: 22px; + height: 22px; fill: white; } .app-name { - font-size: 18px; - font-weight: bold; + font-size: 19px; + font-weight: 700; color: white; + letter-spacing: 0.3px; } .nav-menu { display: flex; flex-direction: column; flex-grow: 1; - padding: 0 15px; + padding: 8px 12px; + gap: 2px; } .nav-item { display: flex; align-items: center; - padding: 12px 15px; - margin-bottom: 5px; - border-radius: 8px; + padding: 11px 14px; + border-radius: 10px; cursor: pointer; - color: white; - font-size: 16px; + color: rgba(255,255,255,0.65); + font-size: 14.5px; + font-weight: 500; + transition: all 0.2s ease; + position: relative; + border-left: 3px solid transparent; + + [dir='rtl'] & { + border-left: none; + border-right: 3px solid transparent; + } } .nav-item:hover { - background-color: #3a4157; + background-color: rgba(255,255,255,0.06); + color: rgba(255,255,255,0.9); } .nav-item.active { - background-color: #374e65; + background-color: rgba(255,94,58,0.12); + color: #fff; + border-left-color: #ff5e3a; + font-weight: 600; + + [dir='rtl'] & { + border-left-color: transparent; + border-right-color: #ff5e3a; + } } .nav-item i { - margin-right: 15px; + margin-right: 14px; width: 20px; text-align: center; + font-size: 16px; + transition: color 0.2s, transform 0.2s; [dir='rtl'] & { - margin-left: 15px; + margin-left: 14px; margin-right: unset; } } +/* Colored icons per section */ +.nav-item:nth-child(1) i { color: #ffa94d; } /* Files - orange */ +.nav-item:nth-child(2) i { color: #74b9ff; } /* Shared - blue */ +.nav-item:nth-child(3) i { color: #81ecec; } /* Recent - teal */ +.nav-item:nth-child(4) i { color: #ffd43b; } /* Favorites - gold */ +.nav-item:nth-child(5) i { color: #ff7675; } /* Trash - red */ + +.nav-item.active:nth-child(1) i { color: #ff5e3a; } +.nav-item.active:nth-child(2) i { color: #0984e3; } +.nav-item.active:nth-child(3) i { color: #00cec9; } +.nav-item.active:nth-child(4) i { color: #f0c800; } +.nav-item.active:nth-child(5) i { color: #e74c3c; } + +.nav-item:hover i { + transform: scale(1.1); +} + /* Storage indicator */ .storage-container { - margin: 20px 15px; - background-color: #374e65; - border-radius: 8px; - padding: 15px; + margin: auto 12px 16px 12px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.07); + border-radius: 12px; + padding: 16px; } .storage-title { - text-align: center; - margin-bottom: 10px; - font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-bottom: 12px; + font-size: 13px; + font-weight: 600; + color: rgba(255,255,255,0.8); + letter-spacing: 0.3px; } .storage-bar { - height: 10px; - background-color: #6b7e8f; - border-radius: 5px; + height: 6px; + background-color: rgba(255,255,255,0.1); + border-radius: 3px; overflow: hidden; margin-bottom: 10px; } .storage-fill { height: 100%; - background-color: #ff5e3a; - width: 0%; /* Will be set dynamically via JavaScript */ + background: linear-gradient(90deg, #ff5e3a 0%, #ff8a5c 100%); + border-radius: 3px; + width: 0%; + transition: width 0.8s ease; } .storage-info { text-align: center; - font-size: 12px; - color: #f5f5f5; + font-size: 11.5px; + color: rgba(255,255,255,0.5); + font-weight: 400; } /* Main content */ @@ -339,31 +396,364 @@ select:focus { gap: 12px; } -.logout-btn { +/* User Menu */ +.user-menu-wrapper { + position: relative; +} + +.user-avatar-btn { + background: none; + border: 2px solid transparent; + border-radius: 50%; + padding: 2px; + cursor: pointer; + transition: all 0.25s ease; display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; - background-color: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 10px; +} + +.user-avatar-btn:hover { + border-color: rgba(255, 94, 58, 0.4); + transform: scale(1.05); +} + +.user-avatar-btn:hover .user-avatar { + box-shadow: 0 0 0 2px rgba(255, 94, 58, 0.2); +} + +.user-menu-wrapper.open .user-avatar-btn { + border-color: #ff5e3a; +} + +.user-avatar { + width: 38px; + height: 38px; + background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 14px; + letter-spacing: 0.5px; + user-select: none; +} + +.user-menu { + display: none; + position: absolute; + top: calc(100% + 10px); + right: 0; + width: 300px; + background: white; + border-radius: 16px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05); + z-index: 2000; + overflow: hidden; + animation: userMenuIn 0.2s ease-out; +} + +.user-menu-wrapper.open .user-menu { + display: block; +} + +@keyframes userMenuIn { + from { + opacity: 0; + transform: translateY(-8px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.user-menu-header { + display: flex; + align-items: center; + gap: 14px; + padding: 20px 20px 16px; + background: linear-gradient(135deg, #fef5f3 0%, #fdf2f8 100%); + border-bottom: 1px solid #fce7e1; +} + +.user-menu-avatar { + width: 48px; + height: 48px; + min-width: 48px; + background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 17px; + letter-spacing: 0.5px; + box-shadow: 0 4px 12px rgba(255, 94, 58, 0.3); +} + +.user-menu-info { + overflow: hidden; +} + +.user-menu-name { + font-weight: 600; + font-size: 15px; + color: #1e293b; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-menu-email { + font-size: 12.5px; + color: #94a3b8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; +} + +.user-menu-storage { + padding: 14px 20px; +} + +.user-menu-storage-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; color: #64748b; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.user-menu-storage-label i { + font-size: 11px; + color: #94a3b8; +} + +.user-menu-storage-bar { + height: 6px; + background: #f1f5f9; + border-radius: 3px; + overflow: hidden; + margin-bottom: 6px; +} + +.user-menu-storage-fill { + height: 100%; + background: linear-gradient(90deg, #ff5e3a, #ff2d55); + border-radius: 3px; + width: 0%; + transition: width 0.5s ease; +} + +.user-menu-storage-text { + font-size: 11.5px; + color: #94a3b8; +} + +.user-menu-divider { + height: 1px; + background: #f1f5f9; + margin: 4px 0; +} + +.user-menu-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 20px; + border: none; + background: none; + color: #334155; + font-size: 14px; cursor: pointer; - font-size: 16px; - transition: all 0.2s ease; + transition: background 0.15s ease; + text-align: left; } -.logout-btn:hover { - background-color: #fef2f2; - border-color: #fecaca; +.user-menu-item:hover { + background: #f8fafc; +} + +.user-menu-item i { + width: 20px; + text-align: center; + font-size: 15px; + color: #64748b; +} + +.user-menu-item .theme-toggle-pill { + margin-left: auto; + width: 40px; + height: 22px; + background: #e2e8f0; + border-radius: 11px; + position: relative; + transition: background 0.3s ease; +} + +.user-menu-item .theme-toggle-pill.active { + background: #ff5e3a; +} + +.user-menu-item .theme-toggle-knob { + width: 18px; + height: 18px; + background: white; + border-radius: 50%; + position: absolute; + top: 2px; + left: 2px; + transition: transform 0.3s ease; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15); +} + +.user-menu-item .theme-toggle-pill.active .theme-toggle-knob { + transform: translateX(18px); +} + +.user-menu-logout { color: #ef4444; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(239, 68, 68, 0.15); + margin-bottom: 4px; } -.logout-btn:active { - transform: translateY(0); +.user-menu-logout i { + color: #ef4444; +} + +.user-menu-logout:hover { + background: #fef2f2; +} + +/* About Modal */ +.about-modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + z-index: 3000; + justify-content: center; + align-items: center; +} + +.about-modal-overlay.show { + display: flex; +} + +.about-modal { + background: white; + border-radius: 20px; + padding: 40px; + max-width: 400px; + width: 90%; + text-align: center; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + animation: userMenuIn 0.25s ease-out; +} + +.about-modal-logo { + width: 72px; + height: 72px; + background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%); + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 20px; + box-shadow: 0 8px 24px rgba(255, 94, 58, 0.3); +} + +.about-modal-logo i { + font-size: 32px; + color: white; +} + +.about-modal h2 { + font-size: 22px; + font-weight: 700; + color: #1e293b; + margin-bottom: 6px; +} + +.about-modal .about-version { + font-size: 13px; + color: #94a3b8; + margin-bottom: 20px; +} + +.about-modal .about-description { + font-size: 14px; + color: #64748b; + line-height: 1.6; + margin-bottom: 24px; +} + +.about-modal .about-tech { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 24px; +} + +.about-modal .about-tech-badge { + padding: 4px 12px; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 20px; + font-size: 12px; + color: #64748b; + font-weight: 500; +} + +.about-modal .about-links { + display: flex; + justify-content: center; + gap: 16px; + margin-bottom: 24px; +} + +.about-modal .about-link { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #ff5e3a; + text-decoration: none; + font-weight: 500; + transition: opacity 0.2s; +} + +.about-modal .about-link:hover { + opacity: 0.8; +} + +.about-modal .about-close-btn { + padding: 10px 32px; + background: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%); + color: white; + border: none; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + box-shadow: 0 4px 15px rgba(255, 94, 58, 0.3); +} + +.about-modal .about-close-btn:hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px rgba(255, 94, 58, 0.4); } /* Language Selector - Custom Dropdown */ @@ -476,18 +866,6 @@ select:focus { opacity: 1; } -.user-avatar { - width: 40px; - height: 40px; - background-color: #ff5e3a; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-weight: bold; -} - /* Content area */ .content-area { flex-grow: 1; @@ -548,6 +926,82 @@ select:focus { box-shadow: 0 2px 10px rgba(255, 94, 58, 0.3); } +/* Upload Dropdown */ +.upload-dropdown { + position: relative; + display: inline-block; +} + +.upload-dropdown .btn-primary { + display: flex; + align-items: center; + gap: 6px; +} + +.upload-dropdown-menu { + display: none; + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 200px; + background: white; + border-radius: 12px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); + border: 1px solid #e2e8f0; + z-index: 1000; + overflow: hidden; + animation: dropdownFadeIn 0.15s ease-out; +} + +.upload-dropdown-menu.show { + display: block; +} + +@keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.upload-dropdown-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px 16px; + border: none; + background: none; + color: #334155; + font-size: 14px; + cursor: pointer; + transition: background 0.15s ease; + text-align: left; +} + +.upload-dropdown-item:hover { + background: #f1f5f9; +} + +.upload-dropdown-item:active { + background: #e2e8f0; +} + +.upload-dropdown-item i { + width: 20px; + text-align: center; + color: #64748b; + font-size: 15px; +} + +.upload-dropdown-item:first-child { + border-bottom: 1px solid #f1f5f9; +} + .btn-secondary { background-color: #f8fafc; color: #4a5568; @@ -628,12 +1082,24 @@ select:focus { grid-template-columns: repeat(auto-fill, minmax(200px, 240px)); gap: 20px; justify-content: start; + position: relative; +} + +/* Rubber band / lasso selection rectangle */ +.selection-rect { + position: fixed; + border: 1.5px solid var(--primary-color, #e67e22); + background-color: rgba(230, 126, 34, 0.08); + pointer-events: none; + z-index: 1000; + border-radius: 3px; + display: none; } .file-card { background-color: white; - border-radius: 8px; - border: 1px solid #e2e8f0; + border-radius: 12px; + border: 2px solid #e2e8f0; padding: 20px; display: flex; flex-direction: column; @@ -643,6 +1109,7 @@ select:focus { transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; width: 100%; min-height: 180px; + position: relative; } .file-card:hover { @@ -651,6 +1118,81 @@ select:focus { border-color: #cbd5e0; } +.file-card.selected { + border-color: #ff5e3a; + background: #fff8f6; + box-shadow: 0 0 0 1px rgba(255, 94, 58, 0.15), 0 4px 12px rgba(255, 94, 58, 0.1); +} + +.file-card.selected:hover { + border-color: #ff5e3a; + box-shadow: 0 0 0 1px rgba(255, 94, 58, 0.2), 0 6px 18px rgba(255, 94, 58, 0.15); +} + +/* Selection checkbox */ +.file-card-checkbox { + position: absolute; + top: 10px; + left: 10px; + width: 22px; + height: 22px; + border-radius: 6px; + border: 2px solid #cbd5e0; + background: white; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s, background 0.15s, border-color 0.15s; + z-index: 10; + cursor: pointer; +} + +.file-card:hover .file-card-checkbox { + opacity: 1; +} + +.file-card.selected .file-card-checkbox { + opacity: 1; + background: #ff5e3a; + border-color: #ff5e3a; +} + +.file-card.selected .file-card-checkbox i { + color: white; + font-size: 11px; +} + +/* More actions button (three dots) */ +.file-card-more { + position: absolute; + top: 8px; + right: 8px; + width: 30px; + height: 30px; + border-radius: 8px; + border: none; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s, background 0.15s; + z-index: 10; + cursor: pointer; + color: #64748b; + font-size: 16px; +} + +.file-card:hover .file-card-more { + opacity: 1; +} + +.file-card-more:hover { + background: #f1f5f9; + color: #334155; +} + .file-card.dragging { opacity: 0.5; transform: scale(0.95); @@ -722,6 +1264,82 @@ select:focus { border-radius: 2px; } +/* Style for PDF files */ +.file-icon.pdf-icon { + width: 100px; + height: 70px; + background-color: #fee2e2; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #e53e3e; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.pdf-icon i { + font-size: 28px; + color: #e53e3e; + opacity: 0.7; +} + +/* Style for document files (doc, docx, txt, rtf, odt) */ +.file-icon.doc-icon { + width: 100px; + height: 70px; + background-color: #ebf5fb; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #2b6cb0; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.doc-icon i { + font-size: 28px; + color: #2b6cb0; + opacity: 0.7; +} + +/* Style for shell/script files */ +.file-icon.script-icon { + width: 100px; + height: 70px; + background-color: #e8f5e9; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #4eaa25; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.script-icon i { + font-size: 28px; + color: #4eaa25; + opacity: 0.7; +} + +/* Style for config files */ +.file-icon.config-icon { + width: 100px; + height: 70px; + background-color: #f1f3f5; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #718096; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.config-icon i { + font-size: 28px; + color: #718096; + opacity: 0.7; +} + /* Style for images */ .file-icon.image-icon { width: 100px; @@ -748,22 +1366,35 @@ select:focus { .file-icon.video-icon { width: 100px; height: 70px; - background-color: #111; /* Black background */ - border-radius: 4px; + background: linear-gradient(135deg, #e0e7ff 0%, #ede9fe 50%, #fce7f3 100%); + border-radius: 10px; position: relative; margin-bottom: 10px; display: flex; align-items: center; justify-content: center; + border: 1px solid rgba(139, 92, 246, 0.15); } .file-icon.video-icon::before { + content: ""; + width: 36px; + height: 36px; + background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%); + border-radius: 50%; + position: absolute; + box-shadow: 0 3px 10px rgba(139, 92, 246, 0.35); +} + +.file-icon.video-icon::after { content: ""; width: 0; height: 0; - border-top: 15px solid transparent; - border-bottom: 15px solid transparent; - border-left: 20px solid white; /* Play triangle */ + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-left: 11px solid white; + position: absolute; + margin-left: 3px; } /* Styles for code files */ @@ -870,6 +1501,226 @@ select:focus { background-color: #3776ab; } +/* TypeScript */ +.file-icon.ts-icon { border-top-color: #3178c6; } +.file-icon.ts-icon::before, .file-icon.ts-icon::after { background-color: #3178c6; } + +/* Rust */ +.file-icon.rust-icon { border-top-color: #dea584; } +.file-icon.rust-icon::before, .file-icon.rust-icon::after { background-color: #dea584; } + +/* Go */ +.file-icon.go-icon { border-top-color: #00add8; } +.file-icon.go-icon::before, .file-icon.go-icon::after { background-color: #00add8; } + +/* Java */ +.file-icon.java-icon { border-top-color: #e76f00; } +.file-icon.java-icon::before, .file-icon.java-icon::after { background-color: #e76f00; } + +/* C / C++ */ +.file-icon.c-icon { border-top-color: #555; } +.file-icon.c-icon::before, .file-icon.c-icon::after { background-color: #555; } + +/* C# */ +.file-icon.cs-icon { border-top-color: #68217a; } +.file-icon.cs-icon::before, .file-icon.cs-icon::after { background-color: #68217a; } + +/* PHP */ +.file-icon.php-icon { border-top-color: #8892be; } +.file-icon.php-icon::before, .file-icon.php-icon::after { background-color: #8892be; } + +/* Ruby */ +.file-icon.ruby-icon { border-top-color: #cc342d; } +.file-icon.ruby-icon::before, .file-icon.ruby-icon::after { background-color: #cc342d; } + +/* Swift */ +.file-icon.swift-icon { border-top-color: #fa7343; } +.file-icon.swift-icon::before, .file-icon.swift-icon::after { background-color: #fa7343; } + +/* Kotlin */ +.file-icon.kotlin-icon { border-top-color: #7f52ff; } +.file-icon.kotlin-icon::before, .file-icon.kotlin-icon::after { background-color: #7f52ff; } + +/* SQL */ +.file-icon.sql-icon { border-top-color: #e38c00; } +.file-icon.sql-icon::before, .file-icon.sql-icon::after { background-color: #e38c00; } + +/* YAML / YML */ +.file-icon.yaml-icon { border-top-color: #cb171e; } +.file-icon.yaml-icon::before, .file-icon.yaml-icon::after { background-color: #cb171e; } + +/* TOML */ +.file-icon.toml-icon { border-top-color: #9c4221; } +.file-icon.toml-icon::before, .file-icon.toml-icon::after { background-color: #9c4221; } + +/* Markdown */ +.file-icon.md-icon { border-top-color: #083fa1; } +.file-icon.md-icon::before, .file-icon.md-icon::after { background-color: #083fa1; } + +/* Shell / Script */ +.file-icon.script-icon { border-top-color: #4eaa25; } +.file-icon.script-icon::before, .file-icon.script-icon::after { background-color: #4eaa25; } + +/* Config files */ +.file-icon.config-icon { border-top-color: #718096; } +.file-icon.config-icon::before, .file-icon.config-icon::after { background-color: #718096; } + +/* Spreadsheet (xlsx, xls, csv, ods) */ +.file-icon.spreadsheet-icon { + width: 100px; + height: 70px; + background-color: #e6f4ea; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #0d904f; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.spreadsheet-icon i { display: none; } +.file-icon.spreadsheet-icon::before { + content: ""; + position: absolute; + top: 12px; left: 18px; + width: 64px; height: 44px; + background: + repeating-linear-gradient(to bottom, #0d904f 0px, #0d904f 1px, transparent 1px, transparent 11px), + repeating-linear-gradient(to right, #0d904f 0px, #0d904f 1px, transparent 1px, transparent 22px); + opacity: 0.35; +} + +/* Presentation (pptx, ppt, odp, key) */ +.file-icon.presentation-icon { + width: 100px; + height: 70px; + background-color: #fef3e2; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #d04423; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.presentation-icon i { display: none; } +.file-icon.presentation-icon::before { + content: ""; + position: absolute; + top: 14px; left: 22px; + width: 56px; height: 38px; + border: 2px solid #d04423; + border-radius: 4px; + opacity: 0.4; +} +.file-icon.presentation-icon::after { + content: ""; + position: absolute; + top: 24px; left: 38px; + width: 0; height: 0; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + border-left: 14px solid #d04423; + opacity: 0.5; +} + +/* Audio files */ +.file-icon.audio-icon { + width: 100px; + height: 70px; + background-color: #fff3e0; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #f57c00; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.audio-icon i { display: none; } +.file-icon.audio-icon::before { + content: "♪"; + font-size: 28px; + color: #f57c00; + opacity: 0.6; +} + +/* Archive (zip, rar, tar, gz, 7z) */ +.file-icon.archive-icon { + width: 100px; + height: 70px; + background-color: #f5f0eb; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #8d6e63; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.archive-icon i { display: none; } +.file-icon.archive-icon::before { + content: ""; + position: absolute; + top: 10px; left: 45px; + width: 10px; height: 46px; + background: repeating-linear-gradient(to bottom, #8d6e63 0px, #8d6e63 5px, #f5f0eb 5px, #f5f0eb 10px); + opacity: 0.5; +} + +/* Installer (exe, msi, dmg, deb, rpm, appimage) */ +.file-icon.installer-icon { + width: 100px; + height: 70px; + background-color: #f3e8ff; + border-radius: 4px; + position: relative; + margin-bottom: 10px; + border-top: 3px solid #7c3aed; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} +.file-icon.installer-icon i { display: none; } +.file-icon.installer-icon::before { + content: "⬇"; + font-size: 24px; + color: #7c3aed; + opacity: 0.5; +} + +/* List view icon colors for new types */ +.file-item .file-icon.spreadsheet-icon { background-color: #e6f4ea; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } +.file-item .file-icon.spreadsheet-icon i { color: #0d904f; display: flex; } +.file-item .file-icon.spreadsheet-icon::before { display: none; } + +.file-item .file-icon.presentation-icon { background-color: #fef3e2; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } +.file-item .file-icon.presentation-icon i { color: #d04423; display: flex; } +.file-item .file-icon.presentation-icon::before, .file-item .file-icon.presentation-icon::after { display: none; } + +.file-item .file-icon.audio-icon { background-color: #fff3e0; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } +.file-item .file-icon.audio-icon i { color: #f57c00; display: flex; } +.file-item .file-icon.audio-icon::before { display: none; } + +.file-item .file-icon.archive-icon { background-color: #f5f0eb; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } +.file-item .file-icon.archive-icon i { color: #8d6e63; display: flex; } +.file-item .file-icon.archive-icon::before { display: none; } + +.file-item .file-icon.installer-icon { background-color: #f3e8ff; width: 36px; height: 36px; margin-bottom: 0; border-top: none; } +.file-item .file-icon.installer-icon i { color: #7c3aed; display: flex; } +.file-item .file-icon.installer-icon::before { display: none; } + +.file-item .file-icon.script-icon { background-color: #e8f5e9; } +.file-item .file-icon.script-icon i { color: #4eaa25; } + +.file-item .file-icon.config-icon { background-color: #f1f3f5; } +.file-item .file-icon.config-icon i { color: #718096; } + .file-name { font-size: 14px; font-weight: 500; @@ -983,11 +1834,11 @@ select:focus { } .file-item .file-icon.video-icon { - background-color: #e2e8f0; + background: linear-gradient(135deg, #ede9fe, #fce7f3); } .file-item .file-icon.video-icon i { - color: #ef4444; + color: #8b5cf6; } .file-item .file-icon.audio-icon { @@ -1057,81 +1908,154 @@ select:focus { /* Context menu */ .context-menu { position: absolute; - background-color: white; - border: 1px solid #ddd; - border-radius: 8px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); - padding: 5px 0; - min-width: 150px; - z-index: 1000; + background: white; + border: 1px solid #e2e8f0; + border-radius: 14px; + box-shadow: 0 10px 36px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04); + padding: 6px; + min-width: 200px; + z-index: 2000; display: none; + animation: contextMenuIn 0.15s ease-out; } -.context-menu-item { - padding: 8px 15px; - cursor: pointer; - display: flex; - align-items: center; - color: #333; -} - -.context-menu-item:hover { - background-color: #f0f8ff; -} - -.context-menu-item i { - margin-right: 8px; - width: 16px; - text-align: center; - - [dir='rtl'] & { - margin-left: 8px; - margin-right: unset; +@keyframes contextMenuIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); } } -/* Dialog */ +.context-menu-item { + padding: 10px 14px; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + color: #334155; + font-size: 14px; + border-radius: 8px; + transition: background 0.12s ease; +} + +.context-menu-item:hover { + background: #f1f5f9; +} + +.context-menu-item:active { + background: #e2e8f0; +} + +.context-menu-item i { + width: 18px; + text-align: center; + font-size: 14px; + color: #64748b; + + [dir='rtl'] & { + margin-left: 0; + margin-right: 0; + } +} + +.context-menu-item-danger { + color: #ef4444; +} + +.context-menu-item-danger:hover { + background: #fef2f2; +} + +.context-menu-item-danger i { + color: #ef4444; +} + +.context-menu-separator { + height: 1px; + background: #f1f5f9; + margin: 4px 8px; +} + +/* Dialog (Rename / Move / Confirm) — Modern Style */ .rename-dialog { position: fixed; top: 0; left: 0; width: 100%; height: 100%; - background-color: rgba(0,0,0,0.5); - display: flex; + background-color: rgba(0,0,0,0.45); + display: none; align-items: center; justify-content: center; - z-index: 2000; - display: none; + z-index: 3000; + backdrop-filter: blur(2px); + animation: modalFadeIn 0.2s ease; +} + +@keyframes modalFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalSlideIn { + from { transform: scale(0.95) translateY(-10px); opacity: 0; } + to { transform: scale(1) translateY(0); opacity: 1; } } .rename-dialog-content { background-color: white; - border-radius: 8px; - padding: 20px; - width: 400px; + border-radius: 16px; + width: 420px; max-width: 90%; + box-shadow: 0 20px 60px rgba(0,0,0,0.25); + overflow: hidden; + animation: modalSlideIn 0.25s ease; } .rename-dialog-header { - font-size: 18px; - font-weight: bold; - margin-bottom: 15px; + font-size: 17px; + font-weight: 600; + color: #1a202c; + padding: 20px 24px; + border-bottom: 1px solid #e2e8f0; + display: flex; + align-items: center; + gap: 12px; +} + +.rename-dialog-body { + padding: 24px; } .rename-dialog input { width: 100%; - padding: 10px; - margin-bottom: 15px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; + padding: 12px 16px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 15px; + background: #f8fafc; + color: #1a202c; + transition: all 0.15s ease; + outline: none; +} + +.rename-dialog input:focus { + border-color: #ff5e3a; + background: white; + box-shadow: 0 0 0 3px rgba(255, 94, 58, 0.1); } .rename-dialog-buttons { display: flex; justify-content: flex-end; - gap: 10px; + gap: 12px; + padding: 16px 24px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; } /* Dropzone */ @@ -1384,68 +2308,240 @@ select:focus { transform: translateY(0); } -/* Dialogs (Rename, Move, Share) */ -.rename-dialog, .share-dialog { +/* Share Dialog — Modern Style */ +.share-dialog { position: fixed; top: 0; left: 0; width: 100%; height: 100%; - background-color: rgba(0, 0, 0, 0.5); + background-color: rgba(0,0,0,0.45); display: none; justify-content: center; align-items: center; - z-index: 2000; -} - -.rename-dialog-content, .share-dialog-content { - background-color: white; - padding: 20px; - border-radius: 8px; - width: 400px; - max-width: 90%; + z-index: 3000; + backdrop-filter: blur(2px); + animation: modalFadeIn 0.2s ease; } .share-dialog-content { - width: 500px; + 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; } -.rename-dialog-header, .share-dialog-header { - font-size: 18px; - font-weight: bold; - margin-bottom: 15px; +.share-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; } -.rename-dialog input, .share-dialog input { +.share-dialog input, .share-dialog textarea { width: 100%; - padding: 10px; - margin-bottom: 15px; - border: 1px solid #ddd; - border-radius: 4px; + padding: 10px 14px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 14px; + background: #f8fafc; + color: #1a202c; + transition: all 0.15s ease; + outline: none; } -.rename-dialog-buttons, .share-dialog-buttons { +.share-dialog input:focus, .share-dialog textarea:focus { + border-color: #ff5e3a; + background: white; + box-shadow: 0 0 0 3px rgba(255,94,58,0.1); +} + +.share-dialog-buttons { display: flex; justify-content: flex-end; - gap: 10px; + gap: 12px; + padding: 16px 24px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; } /* Share dialog specific styles */ .shared-item-info { - padding: 10px 0; - margin-bottom: 15px; - border-bottom: 1px solid #eee; + padding: 12px 24px; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; + font-size: 14px; +} + +.share-options { + padding: 20px 24px 0; } .share-options h3, #existing-shares-section h3, #new-share-section h3 { - font-size: 14px; + font-size: 13px; font-weight: 600; margin-bottom: 10px; - color: #333; + color: #4a5568; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +#existing-shares-section, #new-share-section { + padding: 0 24px; } .form-group { margin-bottom: 15px; + padding: 0 24px; +} + +.share-dialog .form-group { + padding: 0; +} + +/* Folder select items in move dialog */ +.folder-select-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + color: #4a5568; + font-size: 14px; +} + +.folder-select-item:hover { + background-color: #f0f3f7; +} + +.folder-select-item.selected { + background-color: rgba(255,94,58,0.1); + color: #ff5e3a; + font-weight: 600; +} + +.folder-select-item i { + color: #ffa94d; + font-size: 16px; +} + +.folder-select-item.selected i { + color: #ff5e3a; +} + +/* Custom confirm dialog */ +.confirm-dialog { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + background-color: rgba(0,0,0,0.45); + display: none; + align-items: center; + justify-content: center; + z-index: 4000; + backdrop-filter: blur(2px); + animation: modalFadeIn 0.2s ease; +} + +.confirm-dialog-content { + background: white; + border-radius: 16px; + width: 400px; + max-width: 90%; + box-shadow: 0 20px 60px rgba(0,0,0,0.25); + overflow: hidden; + animation: modalSlideIn 0.25s ease; + text-align: center; +} + +.confirm-dialog-icon { + padding: 28px 24px 12px; +} + +.confirm-dialog-icon i { + font-size: 40px; + color: #f56565; +} + +.confirm-dialog-title { + font-size: 17px; + font-weight: 600; + color: #1a202c; + padding: 0 24px 8px; +} + +.confirm-dialog-message { + font-size: 14px; + color: #718096; + padding: 0 24px 20px; + line-height: 1.5; +} + +.confirm-dialog-buttons { + display: flex; + gap: 12px; + padding: 16px 24px; + background: #f8fafc; + border-top: 1px solid #e2e8f0; + justify-content: flex-end; +} + +.confirm-dialog-buttons .btn-danger { + background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%); + color: white; + border: none; + padding: 10px 20px; + border-radius: 10px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + box-shadow: 0 2px 8px rgba(229,62,62,0.3); +} + +.confirm-dialog-buttons .btn-danger:hover { + box-shadow: 0 4px 12px rgba(229,62,62,0.4); + transform: translateY(-1px); +} + +/* Loading spinner overlay */ +.files-loading-spinner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 20px; + gap: 16px; + grid-column: 1 / -1; +} + +.files-loading-spinner .spinner { + width: 36px; + height: 36px; + border: 3px solid #e2e8f0; + border-top-color: #ff5e3a; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.files-loading-spinner span { + font-size: 14px; + color: #a0aec0; + font-weight: 500; } .form-group label { @@ -2131,27 +3227,53 @@ header { /* Responsive */ @media (max-width: 768px) { .sidebar { - width: 60px; - padding: 15px 10px; + width: 64px; + padding: 0; + } + + .logo-container { + justify-content: center; + padding: 16px 0; + } + + .logo { + margin-right: 0; } .app-name, .storage-title, .storage-info { display: none; } + .nav-menu { + padding: 8px 8px; + } + .nav-item { justify-content: center; padding: 12px 0; + border-left-width: 0; + border-bottom: 3px solid transparent; + } + + .nav-item.active { + border-left-color: transparent; + border-bottom-color: #ff5e3a; } .nav-item i { margin-right: 0; + font-size: 18px; } .nav-item span { display: none; } + .storage-container { + margin: auto 8px 12px 8px; + padding: 10px 6px; + } + /* Responsive styles for shared page */ .shared-filters { flex-direction: column; diff --git a/static/index.html b/static/index.html index 6f3dab62..0ae0e039 100644 --- a/static/index.html +++ b/static/index.html @@ -80,7 +80,7 @@
-
Storage
+
Storage
@@ -102,9 +102,46 @@
-
AD
-
- +
+ +
+
+
AD
+
+
Usuario
+
usuario@oxicloud.app
+
+
+
+
+ + Almacenamiento +
+
+
+
+
0% usado
+
+
+ + +
+ +
@@ -114,10 +151,23 @@
- +
+ +
+ + +
+
+ + +
+
+ +

OxiCloud

+
v...
+
+ Cloud storage platform built with Rust & Clean Architecture. Fast, secure, and private. +
+
+ Rust + Axum + PostgreSQL + Clean Architecture +
+ + +
+
diff --git a/static/js/app.js b/static/js/app.js index e1b7287c..34fb0d39 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -126,7 +126,6 @@ function cacheElements() { elements.gridViewBtn = document.getElementById('grid-view-btn'); elements.listViewBtn = document.getElementById('list-view-btn'); elements.breadcrumb = document.querySelector('.breadcrumb'); - elements.logoutBtn = document.getElementById('logout-btn'); elements.pageTitle = document.querySelector('.page-title'); elements.actionsBar = document.querySelector('.actions-bar'); elements.navItems = document.querySelectorAll('.nav-item'); @@ -134,6 +133,197 @@ function cacheElements() { elements.searchInput = document.querySelector('.search-container input'); } +/** + * Setup the user menu (avatar dropdown with profile, storage, theme, about, logout) + */ +function setupUserMenu() { + const wrapper = document.getElementById('user-menu-wrapper'); + const avatarBtn = document.getElementById('user-avatar-btn'); + const menu = document.getElementById('user-menu'); + const logoutBtn = document.getElementById('user-menu-logout'); + const themeBtn = document.getElementById('user-menu-theme'); + const aboutBtn = document.getElementById('user-menu-about'); + + if (!wrapper || !avatarBtn || !menu) return; + + // Toggle menu + avatarBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = wrapper.classList.contains('open'); + wrapper.classList.toggle('open'); + if (!isOpen) { + updateUserMenuData(); + } + }); + + // Close menu on outside click + document.addEventListener('click', (e) => { + if (wrapper.classList.contains('open') && !wrapper.contains(e.target)) { + wrapper.classList.remove('open'); + } + }); + + // Logout + if (logoutBtn) { + logoutBtn.addEventListener('click', () => { + wrapper.classList.remove('open'); + logout(); + }); + } + + // Theme toggle (dark mode placeholder — toggles pill visually) + if (themeBtn) { + const pill = document.getElementById('theme-toggle-pill'); + const isDark = localStorage.getItem('oxicloud_theme') === 'dark'; + if (isDark && pill) pill.classList.add('active'); + + themeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (pill) { + pill.classList.toggle('active'); + const dark = pill.classList.contains('active'); + localStorage.setItem('oxicloud_theme', dark ? 'dark' : 'light'); + // Theme switching could be expanded here in the future + window.ui.showNotification( + dark ? '🌙' : '☀️', + dark ? 'Modo oscuro activado (próximamente)' : 'Modo claro activado' + ); + } + }); + } + + // About modal + if (aboutBtn) { + aboutBtn.addEventListener('click', () => { + wrapper.classList.remove('open'); + const overlay = document.getElementById('about-modal-overlay'); + if (overlay) overlay.classList.add('show'); + }); + } + + // About modal close + const aboutCloseBtn = document.getElementById('about-close-btn'); + const aboutOverlay = document.getElementById('about-modal-overlay'); + if (aboutCloseBtn) { + aboutCloseBtn.addEventListener('click', () => { + aboutOverlay.classList.remove('show'); + }); + } + if (aboutOverlay) { + aboutOverlay.addEventListener('click', (e) => { + if (e.target === aboutOverlay) { + aboutOverlay.classList.remove('show'); + } + }); + } + + // Fetch version from backend (centralized in Cargo.toml) + fetchAppVersion(); +} + +/** + * Update user menu data (name, email, storage) from localStorage + */ +function updateUserMenuData() { + const USER_DATA_KEY = 'oxicloud_user'; + const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}'); + + const nameEl = document.getElementById('user-menu-name'); + const emailEl = document.getElementById('user-menu-email'); + const avatarEl = document.getElementById('user-menu-avatar'); + const storageFill = document.getElementById('user-menu-storage-fill'); + const storageText = document.getElementById('user-menu-storage-text'); + + if (userData.username) { + if (nameEl) nameEl.textContent = userData.username; + if (emailEl) emailEl.textContent = userData.email || ''; + if (avatarEl) avatarEl.textContent = userData.username.substring(0, 2).toUpperCase(); + } + + // Storage info + const usedBytes = userData.storage_used_bytes || 0; + const quotaBytes = userData.storage_quota_bytes || 10737418240; + const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0; + + if (storageFill) storageFill.style.width = percentage + '%'; + if (storageText) { + const used = formatFileSize(usedBytes); + const total = formatFileSize(quotaBytes); + storageText.textContent = `${percentage}% · ${used} / ${total}`; + } +} + +/** + * Fetch app version from backend (centralized in Cargo.toml) + * Updates the about modal version display + */ +async function fetchAppVersion() { + try { + const response = await fetch('/api/version'); + if (response.ok) { + const data = await response.json(); + const versionEl = document.getElementById('about-version'); + if (versionEl && data.version) { + versionEl.textContent = `v${data.version}`; + } + } + } catch (err) { + console.warn('Could not fetch app version:', err); + // Fallback: leave placeholder + } +} + +/** + * Setup the upload dropdown button and menu + * Handles opening/closing the dropdown and triggering file/folder inputs + */ +function setupUploadDropdown() { + const dropdown = document.getElementById('upload-dropdown'); + const uploadBtn = document.getElementById('upload-btn'); + const menu = document.getElementById('upload-dropdown-menu'); + const uploadFilesBtn = document.getElementById('upload-files-btn'); + const uploadFolderBtn = document.getElementById('upload-folder-btn'); + + if (!uploadBtn || !menu) return; + + // Toggle dropdown on button click + uploadBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isOpen = menu.classList.contains('show'); + // Close any other open dropdowns + document.querySelectorAll('.upload-dropdown-menu.show').forEach(m => m.classList.remove('show')); + if (!isOpen) { + menu.classList.add('show'); + } + }); + + // Upload files option + if (uploadFilesBtn) { + uploadFilesBtn.addEventListener('click', (e) => { + e.stopPropagation(); + menu.classList.remove('show'); + elements.fileInput.click(); + }); + } + + // Upload folder option + if (uploadFolderBtn) { + uploadFolderBtn.addEventListener('click', (e) => { + e.stopPropagation(); + menu.classList.remove('show'); + const folderInput = document.getElementById('folder-input'); + if (folderInput) { + folderInput.click(); + } + }); + } + + // Close dropdown when clicking outside + document.addEventListener('click', () => { + document.querySelectorAll('.upload-dropdown-menu.show').forEach(m => m.classList.remove('show')); + }); +} + /** * Setup event listeners for main UI elements */ @@ -165,21 +355,28 @@ function setupEventListeners() { } }); - // Upload button - elements.uploadBtn.addEventListener('click', () => { - elements.dropzone.style.display = elements.dropzone.style.display === 'none' ? 'block' : 'none'; - if (elements.dropzone.style.display === 'block') { - elements.fileInput.click(); - } - }); + // Upload dropdown + setupUploadDropdown(); // File input elements.fileInput.addEventListener('change', (e) => { if (e.target.files.length > 0) { fileOps.uploadFiles(e.target.files); + e.target.value = ''; // reset so same file can be re-uploaded } }); + // Folder input + const folderInput = document.getElementById('folder-input'); + if (folderInput) { + folderInput.addEventListener('change', (e) => { + if (e.target.files.length > 0) { + fileOps.uploadFolderFiles(e.target.files); + e.target.value = ''; + } + }); + } + // New folder button elements.newFolderBtn.addEventListener('click', async () => { const folderName = await window.Modal.promptNewFolder(); @@ -298,9 +495,23 @@ function setupEventListeners() { elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.files') : 'Archivos'; elements.actionsBar.innerHTML = `
- +
+ +
+ + +
+
@@ -323,12 +534,7 @@ function setupEventListeners() { if (filesListView) filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; // Restore event listeners - document.getElementById('upload-btn').addEventListener('click', () => { - elements.dropzone.style.display = elements.dropzone.style.display === 'none' ? 'block' : 'none'; - if (elements.dropzone.style.display === 'block') { - elements.fileInput.click(); - } - }); + setupUploadDropdown(); document.getElementById('new-folder-btn').addEventListener('click', async () => { const folderName = await window.Modal.promptNewFolder(); @@ -360,10 +566,10 @@ function setupEventListeners() { ui.switchToListView(); } - // Logout button - elements.logoutBtn.addEventListener('click', logout); + // User menu + setupUserMenu(); - // Global events to close context menus + // Global events to close context menus and deselect cards document.addEventListener('click', (e) => { const folderMenu = document.getElementById('folder-context-menu'); const fileMenu = document.getElementById('file-context-menu'); @@ -377,6 +583,11 @@ function setupEventListeners() { !fileMenu.contains(e.target)) { ui.closeFileContextMenu(); } + + // Deselect all cards when clicking empty area (not on a card, menu, or modal) + if (!e.target.closest('.file-card') && !e.target.closest('.context-menu') && !e.target.closest('.about-modal')) { + document.querySelectorAll('.file-card.selected').forEach(c => c.classList.remove('selected')); + } }); } @@ -398,6 +609,14 @@ async function loadFiles(options = {}) { window.isLoadingFiles = true; + // Show loading spinner + elements.filesGrid.innerHTML = ` +
+
+ ${window.i18n ? window.i18n.t('files.loading') : 'Cargando archivos…'} +
+ `; + // Always ensure a userHomeFolderId is set if (!app.userHomeFolderId) { // If we don't have a home folder ID yet, try to get the user's username @@ -606,8 +825,8 @@ async function loadTrashItems() { window.i18n.translatePage(); } - // Update breadcrumb for trash - ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.trash') : 'Papelera'); + // Update breadcrumb - just show Home + ui.updateBreadcrumb(''); // Get trash items const trashItems = await fileOps.getTrashItems(); @@ -833,7 +1052,7 @@ function switchToSharedView() { elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.shared') : 'Compartidos'; // Clear breadcrumb and show root - ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.shared') : 'Compartidos'); + ui.updateBreadcrumb(''); // Hide standard actions bar if (elements.actionsBar) { @@ -873,9 +1092,23 @@ function switchToFilesView() { // Reset UI elements.actionsBar.innerHTML = `
- +
+ +
+ + +
+
@@ -892,12 +1125,7 @@ function switchToFilesView() { elements.actionsBar.style.display = 'flex'; // Restore event listeners - document.getElementById('upload-btn').addEventListener('click', () => { - elements.dropzone.style.display = elements.dropzone.style.display === 'none' ? 'block' : 'none'; - if (elements.dropzone.style.display === 'block') { - elements.fileInput.click(); - } - }); + setupUploadDropdown(); document.getElementById('new-folder-btn').addEventListener('click', async () => { const folderName = await window.Modal.promptNewFolder(); @@ -967,7 +1195,7 @@ function switchToFavoritesView() { elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.favorites') : 'Favoritos'; // Clear breadcrumb and show root - ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.favorites') : 'Favoritos'); + ui.updateBreadcrumb(''); // Hide shared view if it exists if (window.sharedView) { @@ -1056,7 +1284,7 @@ function switchToRecentFilesView() { elements.pageTitle.textContent = window.i18n ? window.i18n.t('nav.recent') : 'Recientes'; // Clear breadcrumb and show root - ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.recent') : 'Recientes'); + ui.updateBreadcrumb(''); // Hide shared view if it exists if (window.sharedView) { @@ -1231,20 +1459,14 @@ function checkAuthentication() { localStorage.setItem(USER_DATA_KEY, JSON.stringify(defaultUserData)); // Update avatar with default initials - const userAvatar = document.querySelector('.user-avatar'); - if (userAvatar) { - userAvatar.textContent = 'US'; - } + document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => el.textContent = 'US'); // Update storage display with default values updateStorageUsageDisplay(defaultUserData); } else { // Update avatar with user initials const userInitials = userData.username.substring(0, 2).toUpperCase(); - const userAvatar = document.querySelector('.user-avatar'); - if (userAvatar) { - userAvatar.textContent = userInitials; - } + document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => el.textContent = userInitials); // Show cached storage first, then try to refresh from server updateStorageUsageDisplay(userData); @@ -1302,10 +1524,14 @@ function checkAuthentication() { if (userData.username) { // Update user avatar with initials const userInitials = userData.username.substring(0, 2).toUpperCase(); - const userAvatar = document.querySelector('.user-avatar'); - if (userAvatar) { - userAvatar.textContent = userInitials; - } + document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => { + el.textContent = userInitials; + }); + // Update user menu info + const menuName = document.getElementById('user-menu-name'); + const menuEmail = document.getElementById('user-menu-email'); + if (menuName) menuName.textContent = userData.username; + if (menuEmail) menuEmail.textContent = userData.email || ''; // Update storage usage information with cached data first (for fast display) updateStorageUsageDisplay(userData); @@ -1335,10 +1561,7 @@ function checkAuthentication() { localStorage.setItem(USER_DATA_KEY, JSON.stringify(defaultUserData)); // Update avatar with default initials - const userAvatar = document.querySelector('.user-avatar'); - if (userAvatar) { - userAvatar.textContent = 'US'; - } + document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => el.textContent = 'US'); // Update storage display with default values updateStorageUsageDisplay(defaultUserData); @@ -1367,10 +1590,7 @@ function checkAuthentication() { localStorage.setItem('oxicloud_user', JSON.stringify(defaultUserData)); // Update avatar - const userAvatar = document.querySelector('.user-avatar'); - if (userAvatar) { - userAvatar.textContent = 'US'; - } + document.querySelectorAll('.user-avatar, .user-menu-avatar').forEach(el => el.textContent = 'US'); // Update storage display with default values updateStorageUsageDisplay(defaultUserData); diff --git a/static/js/auth.js b/static/js/auth.js index cb0920ad..184f932e 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -23,25 +23,79 @@ const LANGUAGE_TEXTS = { en: { title: 'Welcome to OxiCloud', subtitle: 'Please select your language', - continue: 'Continue' + continue: 'Continue', + autodetected: 'We detected your language', + moreLanguages: 'More languages...', + modalTitle: 'Select language', + searchPlaceholder: 'Search language...' }, es: { title: 'Bienvenido a OxiCloud', subtitle: 'Por favor, selecciona tu idioma', - continue: 'Continuar' + continue: 'Continuar', + autodetected: 'Hemos detectado tu idioma', + moreLanguages: 'Más idiomas...', + modalTitle: 'Seleccionar idioma', + searchPlaceholder: 'Buscar idioma...' }, zh: { title: '欢迎使用 OxiCloud', subtitle: '请选择您的语言', - continue: '继续' + continue: '继续', + autodetected: '我们检测到了您的语言', + moreLanguages: '更多语言...', + modalTitle: '选择语言', + searchPlaceholder: '搜索语言...' }, fa: { title: 'به OxiCloud خوش آمدید', subtitle: 'لطفا زبان خود را انتخاب کنید', - continue: 'ادامه' + continue: 'ادامه', + autodetected: 'زبان شما شناسایی شد', + moreLanguages: 'زبان‌های بیشتر...', + modalTitle: 'انتخاب زبان', + searchPlaceholder: 'جستجوی زبان...' } }; +// Complete language registry — add new languages here, they'll appear automatically +// `popular: true` languages show as cards on the main screen, the rest in the modal +const ALL_LANGUAGES = [ + { code: 'en', name: 'English', nativeName: 'English', flag: '🇬🇧', popular: true }, + { code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', popular: true }, + { code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', popular: true }, + { code: 'fa', name: 'Persian', nativeName: 'فارسی', flag: '🇮🇷', popular: true }, + { code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', popular: false }, + { code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', popular: false }, + { code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷', popular: false }, + { code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', popular: false }, + { code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', popular: false }, + { code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵', popular: false }, + { code: 'ko', name: 'Korean', nativeName: '한국어', flag: '🇰🇷', popular: false }, + { code: 'ar', name: 'Arabic', nativeName: 'العربية', flag: '🇸🇦', popular: false }, + { code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', popular: false }, + { code: 'tr', name: 'Turkish', nativeName: 'Türkçe', flag: '🇹🇷', popular: false }, + { code: 'nl', name: 'Dutch', nativeName: 'Nederlands', flag: '🇳🇱', popular: false }, + { code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱', popular: false }, + { code: 'sv', name: 'Swedish', nativeName: 'Svenska', flag: '🇸🇪', popular: false }, + { code: 'da', name: 'Danish', nativeName: 'Dansk', flag: '🇩🇰', popular: false }, + { code: 'fi', name: 'Finnish', nativeName: 'Suomi', flag: '🇫🇮', popular: false }, + { code: 'no', name: 'Norwegian', nativeName: 'Norsk', flag: '🇳🇴', popular: false }, + { code: 'uk', name: 'Ukrainian', nativeName: 'Українська', flag: '🇺🇦', popular: false }, + { code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿', popular: false }, + { code: 'el', name: 'Greek', nativeName: 'Ελληνικά', flag: '🇬🇷', popular: false }, + { code: 'he', name: 'Hebrew', nativeName: 'עברית', flag: '🇮🇱', popular: false }, + { code: 'th', name: 'Thai', nativeName: 'ไทย', flag: '🇹🇭', popular: false }, + { code: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt', flag: '🇻🇳', popular: false }, + { code: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia', flag: '🇮🇩', popular: false }, + { code: 'ms', name: 'Malay', nativeName: 'Bahasa Melayu', flag: '🇲🇾', popular: false }, + { code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', popular: false }, + { code: 'hu', name: 'Hungarian', nativeName: 'Magyar', flag: '🇭🇺', popular: false }, + { code: 'ca', name: 'Catalan', nativeName: 'Català', flag: '🏴', popular: false }, + { code: 'eu', name: 'Basque', nativeName: 'Euskara', flag: '🏴', popular: false }, + { code: 'gl', name: 'Galician', nativeName: 'Galego', flag: '🏴', popular: false }, +]; + // Check if this is a first run (no locale saved) function isFirstRun() { return !localStorage.getItem(LOCALE_KEY); @@ -62,35 +116,117 @@ async function checkSystemStatus() { } } -// Initialize language selector panel +// Detect user's browser language and return the best matching language from ALL_LANGUAGES +function detectBrowserLanguage() { + const browserLangs = navigator.languages || [navigator.language || navigator.userLanguage || 'en']; + for (const bl of browserLangs) { + const code = bl.substring(0, 2).toLowerCase(); + const match = ALL_LANGUAGES.find(l => l.code === code); + if (match) return match; + } + return ALL_LANGUAGES[0]; // fallback to English +} + +// Build a language option element (card style) +function buildLanguageCard(lang, isSelected) { + const label = document.createElement('label'); + label.className = 'language-option' + (isSelected ? ' selected' : ''); + label.setAttribute('data-lang', lang.code); + label.innerHTML = ` + + + ${lang.flag} + ${lang.nativeName} + `; + return label; +} + +// Initialize language selector panel with hybrid approach function initLanguageSelector() { const languagePanel = document.getElementById('language-panel'); - const languageOptions = document.querySelectorAll('.language-option'); const continueBtn = document.getElementById('language-continue'); + const optionsContainer = document.getElementById('language-options'); + const moreLangBtn = document.getElementById('lang-more-btn'); + + if (!languagePanel || !optionsContainer) return; + let selectedLanguage = null; - if (!languagePanel) return; + // --- Auto-detect browser language --- + const detected = detectBrowserLanguage(); - // Handle language option clicks - languageOptions.forEach(option => { - option.addEventListener('click', () => { - // Remove selected class from all options - languageOptions.forEach(opt => opt.classList.remove('selected')); - // Add selected class to clicked option - option.classList.add('selected'); - // Check the radio button - option.querySelector('input[type="radio"]').checked = true; - // Store selected language - selectedLanguage = option.getAttribute('data-lang'); - // Enable continue button + // Build the list of popular languages to show as cards + // If the detected language isn't already popular, promote it to the top + let popularLangs = ALL_LANGUAGES.filter(l => l.popular); + const detectedInPopular = popularLangs.find(l => l.code === detected.code); + if (!detectedInPopular) { + // Insert detected language at the top of popular cards + popularLangs = [detected, ...popularLangs]; + } + + // Auto-select the detected language + selectedLanguage = detected.code; + continueBtn.disabled = false; + + // Show autodetection banner + const autodetectedBanner = document.getElementById('lang-autodetected'); + if (autodetectedBanner) { + autodetectedBanner.style.display = 'flex'; + } + updateLanguagePanelTexts(detected.code); + + // --- Render popular language cards --- + optionsContainer.innerHTML = ''; + popularLangs.forEach(lang => { + const card = buildLanguageCard(lang, lang.code === selectedLanguage); + card.addEventListener('click', () => { + // Deselect all + optionsContainer.querySelectorAll('.language-option').forEach(o => o.classList.remove('selected')); + card.classList.add('selected'); + card.querySelector('input[type="radio"]').checked = true; + selectedLanguage = lang.code; continueBtn.disabled = false; - - // Update UI texts based on selected language - updateLanguagePanelTexts(selectedLanguage); + updateLanguagePanelTexts(lang.code); }); + optionsContainer.appendChild(card); }); - // Handle continue button click + // --- "More languages" button opens the modal --- + if (moreLangBtn) { + moreLangBtn.addEventListener('click', () => openLanguageModal(selectedLanguage, (langCode) => { + selectedLanguage = langCode; + continueBtn.disabled = false; + updateLanguagePanelTexts(langCode); + + // Update cards to reflect new selection + optionsContainer.querySelectorAll('.language-option').forEach(o => { + const isThis = o.getAttribute('data-lang') === langCode; + o.classList.toggle('selected', isThis); + o.querySelector('input[type="radio"]').checked = isThis; + }); + + // If selected lang is not in the popular cards, add it temporarily + if (!optionsContainer.querySelector(`[data-lang="${langCode}"]`)) { + const lang = ALL_LANGUAGES.find(l => l.code === langCode); + if (lang) { + // Deselect all existing + optionsContainer.querySelectorAll('.language-option').forEach(o => o.classList.remove('selected')); + const card = buildLanguageCard(lang, true); + card.addEventListener('click', () => { + optionsContainer.querySelectorAll('.language-option').forEach(o => o.classList.remove('selected')); + card.classList.add('selected'); + card.querySelector('input[type="radio"]').checked = true; + selectedLanguage = lang.code; + updateLanguagePanelTexts(lang.code); + }); + // Insert at the top + optionsContainer.insertBefore(card, optionsContainer.firstChild); + } + } + })); + } + + // --- Continue button --- continueBtn.addEventListener('click', async () => { if (!selectedLanguage) return; @@ -111,19 +247,16 @@ function initLanguageSelector() { console.log('System status after language selection:', systemStatus); if (!systemStatus.initialized) { - // No admin exists - show admin setup console.log('No admin exists, showing admin setup panel'); document.getElementById('login-panel').style.display = 'none'; document.getElementById('register-panel').style.display = 'none'; document.getElementById('admin-setup-panel').style.display = 'block'; - // Hide the "Already set up? Sign in" link const backToLoginLink = document.getElementById('back-to-login'); if (backToLoginLink) { backToLoginLink.parentElement.style.display = 'none'; } } else { - // Admin exists - show login panel document.getElementById('login-panel').style.display = 'block'; } @@ -134,16 +267,100 @@ function initLanguageSelector() { }); } +// Open the full language modal with search +function openLanguageModal(currentSelection, onSelect) { + const overlay = document.getElementById('lang-modal-overlay'); + const list = document.getElementById('lang-modal-list'); + const searchInput = document.getElementById('lang-search-input'); + const closeBtn = document.getElementById('lang-modal-close'); + + if (!overlay || !list) return; + + // Render all languages + function renderList(filter = '') { + list.innerHTML = ''; + const filterLower = filter.toLowerCase(); + + const filtered = ALL_LANGUAGES.filter(lang => { + if (!filter) return true; + return lang.name.toLowerCase().includes(filterLower) || + lang.nativeName.toLowerCase().includes(filterLower) || + lang.code.toLowerCase().includes(filterLower); + }); + + if (filtered.length === 0) { + list.innerHTML = '
No languages found
'; + return; + } + + filtered.forEach(lang => { + const item = document.createElement('div'); + item.className = 'lang-modal-item' + (lang.code === currentSelection ? ' selected' : ''); + item.setAttribute('data-lang', lang.code); + item.innerHTML = ` + ${lang.flag} + ${lang.nativeName} + ${lang.name} + ${lang.code === currentSelection ? '' : ''} + `; + item.addEventListener('click', () => { + currentSelection = lang.code; + onSelect(lang.code); + closeModal(); + }); + list.appendChild(item); + }); + } + + function closeModal() { + overlay.style.display = 'none'; + if (searchInput) searchInput.value = ''; + } + + // Show modal + overlay.style.display = 'flex'; + renderList(); + + // Focus search + if (searchInput) { + setTimeout(() => searchInput.focus(), 100); + searchInput.value = ''; + searchInput.oninput = () => renderList(searchInput.value); + } + + // Close handlers + if (closeBtn) { + closeBtn.onclick = closeModal; + } + overlay.onclick = (e) => { + if (e.target === overlay) closeModal(); + }; + document.addEventListener('keydown', function escHandler(e) { + if (e.key === 'Escape') { + closeModal(); + document.removeEventListener('keydown', escHandler); + } + }); +} + // Update language panel texts based on selected language function updateLanguagePanelTexts(lang) { const texts = LANGUAGE_TEXTS[lang] || LANGUAGE_TEXTS.en; const titleEl = document.getElementById('language-title'); const subtitleEl = document.getElementById('language-subtitle'); const continueBtn = document.getElementById('language-continue'); + const autodetectedText = document.getElementById('lang-autodetected-text'); + const moreText = document.getElementById('lang-more-text'); + const modalTitle = document.getElementById('lang-modal-title'); + const searchInput = document.getElementById('lang-search-input'); if (titleEl) titleEl.textContent = texts.title; if (subtitleEl) subtitleEl.textContent = texts.subtitle; if (continueBtn) continueBtn.textContent = texts.continue; + if (autodetectedText) autodetectedText.textContent = texts.autodetected; + if (moreText) moreText.textContent = texts.moreLanguages; + if (modalTitle) modalTitle.textContent = texts.modalTitle; + if (searchInput) searchInput.placeholder = texts.searchPlaceholder; } // Show appropriate panel based on system status and first run diff --git a/static/js/components/sharedView.js b/static/js/components/sharedView.js index 676d5218..7d467206 100644 --- a/static/js/components/sharedView.js +++ b/static/js/components/sharedView.js @@ -60,14 +60,6 @@ const sharedView = { // Update container sharedContainer.innerHTML = ` -
-
- -
-
-
@@ -234,7 +226,6 @@ const sharedView = { const sortBy = document.getElementById('sort-by'); const searchFilter = document.getElementById('shared-search-filter'); const searchBtn = document.getElementById('shared-search-filter-btn'); - const goToFilesBtn = document.getElementById('go-to-files-btn'); const emptyGoToFiles = document.getElementById('empty-go-to-files'); if (filterType) filterType.addEventListener('change', () => this.filterAndSortItems()); @@ -244,8 +235,7 @@ const sharedView = { }); if (searchBtn) searchBtn.addEventListener('click', () => this.filterAndSortItems()); - // Back to files buttons - if (goToFilesBtn) goToFilesBtn.addEventListener('click', () => window.switchToFilesView()); + // Back to files button (empty state) if (emptyGoToFiles) emptyGoToFiles.addEventListener('click', () => window.switchToFilesView()); // Share dialog buttons diff --git a/static/js/contextMenus.js b/static/js/contextMenus.js index 6e4fc479..3318c7a4 100644 --- a/static/js/contextMenus.js +++ b/static/js/contextMenus.js @@ -82,7 +82,10 @@ const contextMenus = { document.getElementById('view-file-option').addEventListener('click', () => { if (window.app.contextMenuTargetFile) { // Fetch file details to get the mime type - fetch(`/api/files/${window.app.contextMenuTargetFile.id}?metadata=true`) + const token = localStorage.getItem('oxicloud_token'); + fetch(`/api/files/${window.app.contextMenuTargetFile.id}?metadata=true`, { + headers: { 'Authorization': `Bearer ${token}` } + }) .then(response => response.json()) .then(fileDetails => { // Check if viewable file type @@ -157,6 +160,13 @@ const contextMenus = { window.ui.closeFileContextMenu(); }); + document.getElementById('rename-file-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFile) { + this.showRenameFileDialog(window.app.contextMenuTargetFile); + } + window.ui.closeFileContextMenu(); + }); + document.getElementById('move-file-option').addEventListener('click', () => { if (window.app.contextMenuTargetFile) { this.showMoveDialog(window.app.contextMenuTargetFile, 'file'); @@ -187,12 +197,12 @@ const contextMenus = { const renameInput = document.getElementById('rename-input'); renameCancelBtn.addEventListener('click', this.closeRenameDialog); - renameConfirmBtn.addEventListener('click', this.renameFolder); + renameConfirmBtn.addEventListener('click', () => contextMenus.renameItem()); // Rename on Enter key renameInput.addEventListener('keyup', (e) => { if (e.key === 'Enter') { - this.renameFolder(); + contextMenus.renameItem(); } else if (e.key === 'Escape') { this.closeRenameDialog(); } @@ -232,7 +242,29 @@ const contextMenus = { const renameInput = document.getElementById('rename-input'); const renameDialog = document.getElementById('rename-dialog'); + window.app.renameMode = 'folder'; renameInput.value = folder.name; + // Update header text + const headerSpan = renameDialog.querySelector('.rename-dialog-header span'); + if (headerSpan) headerSpan.textContent = window.i18n ? window.i18n.t('dialogs.rename_folder') : 'Renombrar carpeta'; + renameDialog.style.display = 'flex'; + renameInput.focus(); + renameInput.select(); + }, + + /** + * Show rename dialog for a file + * @param {Object} file - File object + */ + showRenameFileDialog(file) { + const renameInput = document.getElementById('rename-input'); + const renameDialog = document.getElementById('rename-dialog'); + + window.app.renameMode = 'file'; + renameInput.value = file.name; + // Update header text + const headerSpan = renameDialog.querySelector('.rename-dialog-header span'); + if (headerSpan) headerSpan.textContent = window.i18n ? window.i18n.t('dialogs.rename_file') : 'Renombrar archivo'; renameDialog.style.display = 'flex'; renameInput.focus(); renameInput.select(); @@ -258,11 +290,12 @@ const contextMenus = { // Reset selection window.app.selectedTargetFolderId = ""; - // Update dialog title + // Update dialog title (preserve icon) const dialogHeader = document.getElementById('move-file-dialog').querySelector('.rename-dialog-header'); - dialogHeader.textContent = mode === 'file' ? + const titleText = mode === 'file' ? (window.i18n ? window.i18n.t('dialogs.move_file') : 'Mover archivo') : (window.i18n ? window.i18n.t('dialogs.move_folder') : 'Mover carpeta'); + dialogHeader.innerHTML = ` ${titleText}`; // Load all available folders await this.loadAllFolders(item.id, mode); @@ -281,24 +314,35 @@ const contextMenus = { }, /** - * Rename the selected folder + * Rename the selected folder or file */ - async renameFolder() { - if (!window.app.contextMenuTargetFolder) return; - + async renameItem() { const newName = document.getElementById('rename-input').value.trim(); if (!newName) { alert(window.i18n ? window.i18n.t('errors.empty_name') : 'El nombre no puede estar vacío'); return; } - const success = await window.fileOps.renameFolder(window.app.contextMenuTargetFolder.id, newName); - if (success) { - contextMenus.closeRenameDialog(); - window.loadFiles(); + if (window.app.renameMode === 'file' && window.app.contextMenuTargetFile) { + const success = await window.fileOps.renameFile(window.app.contextMenuTargetFile.id, newName); + if (success) { + contextMenus.closeRenameDialog(); + window.loadFiles(); + } + } else if (window.app.contextMenuTargetFolder) { + const success = await window.fileOps.renameFolder(window.app.contextMenuTargetFolder.id, newName); + if (success) { + contextMenus.closeRenameDialog(); + window.loadFiles(); + } } }, + // Keep backward compat + renameFolder() { + return contextMenus.renameItem(); + }, + /** * Load all folders for the move dialog * @param {string} itemId - ID of the item being moved @@ -306,7 +350,10 @@ const contextMenus = { */ async loadAllFolders(itemId, mode) { try { - const response = await fetch('/api/folders'); + const token = localStorage.getItem('oxicloud_token'); + const response = await fetch('/api/folders', { + headers: { 'Authorization': `Bearer ${token}` } + }); if (response.ok) { const folders = await response.json(); const folderSelectContainer = document.getElementById('folder-select-container'); @@ -459,15 +506,19 @@ const contextMenus = { e.preventDefault(); const shareId = btn.getAttribute('data-share-id'); - if (confirm('¿Estás seguro de que quieres eliminar este enlace compartido?')) { - window.fileSharing.removeSharedLink(shareId); - btn.closest('.existing-share-item').remove(); - - // Check if we still have shares - if (existingSharesContainer.children.length === 0) { - document.getElementById('existing-shares-section').style.display = 'none'; + showConfirmDialog({ + title: window.i18n ? window.i18n.t('dialogs.confirm_delete_share') : 'Eliminar enlace', + message: window.i18n ? window.i18n.t('dialogs.confirm_delete_share_msg') : '¿Estás seguro de que quieres eliminar este enlace compartido?', + confirmText: window.i18n ? window.i18n.t('actions.delete') : 'Eliminar', + }).then(confirmed => { + if (confirmed) { + window.fileSharing.removeSharedLink(shareId); + btn.closest('.existing-share-item').remove(); + if (existingSharesContainer.children.length === 0) { + document.getElementById('existing-shares-section').style.display = 'none'; + } } - } + }); }); }); } else { diff --git a/static/js/favorites.js b/static/js/favorites.js index 95e90fe6..28484c03 100644 --- a/static/js/favorites.js +++ b/static/js/favorites.js @@ -317,8 +317,8 @@ const favorites = {
`; - // Update breadcrumb for favorites - window.ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.favorites') : 'Favoritos'); + // Update breadcrumb - just show Home + window.ui.updateBreadcrumb(''); // Show empty state if no favorites if (favorites.length === 0) { diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index 2ec6f12b..d945dcc5 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -3,6 +3,19 @@ * This file handles file and folder operations (create, move, delete, rename, upload) */ +/** + * Get authorization headers for API requests + * @returns {Object} Headers object with Authorization bearer token + */ +function getAuthHeaders() { + const token = localStorage.getItem('oxicloud_token'); + const headers = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + // File Operations Module const fileOps = { /** @@ -49,6 +62,7 @@ const fileOps = { // Añadir cache: 'no-store' para evitar problemas de caché durante la subida cache: 'no-store', headers: { + ...getAuthHeaders(), // Agregar este encabezado para forzar recargas frescas 'Cache-Control': 'no-cache, no-store, must-revalidate' } @@ -104,6 +118,133 @@ const fileOps = { } }, + /** + * Upload folder files maintaining directory structure + * Creates subfolders as needed, then uploads files into them + * @param {FileList} files - Files from folder input (with webkitRelativePath) + */ + async uploadFolderFiles(files) { + if (!files || files.length === 0) return; + + const progressBar = document.querySelector('.progress-fill'); + const uploadProgressDiv = document.querySelector('.upload-progress'); + uploadProgressDiv.style.display = 'block'; + progressBar.style.width = '0%'; + + const currentFolderId = window.app.currentPath || window.app.userHomeFolderId; + + // Build folder structure from relative paths + // webkitRelativePath looks like: "folderName/subfolder/file.txt" + const folderMap = new Map(); // path -> folder_id + folderMap.set('', currentFolderId); // root = current folder + + // Collect all unique folder paths + const folderPaths = new Set(); + for (const file of files) { + const parts = file.webkitRelativePath.split('/'); + // Remove filename, keep folder parts + for (let i = 1; i < parts.length; i++) { + const path = parts.slice(0, i).join('/'); + folderPaths.add(path); + } + } + + // Sort paths by depth so parents are created first + const sortedPaths = [...folderPaths].sort((a, b) => + a.split('/').length - b.split('/').length + ); + + // Create folders + for (const folderPath of sortedPaths) { + const parts = folderPath.split('/'); + const folderName = parts[parts.length - 1]; + const parentPath = parts.slice(0, -1).join('/'); + const parentId = folderMap.get(parentPath) || currentFolderId; + + try { + const response = await fetch('/api/folders', { + method: 'POST', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + }, + body: JSON.stringify({ + name: folderName, + parent_id: parentId + }) + }); + + if (response.ok) { + const folder = await response.json(); + folderMap.set(folderPath, folder.id); + console.log(`Created folder: ${folderPath} -> ${folder.id}`); + } else { + console.error(`Error creating folder ${folderPath}:`, await response.text()); + window.ui.showNotification('Error', `Error creando carpeta: ${folderName}`); + } + } catch (error) { + console.error(`Network error creating folder ${folderPath}:`, error); + } + } + + // Upload files into their respective folders + let uploadedCount = 0; + const totalFiles = files.length; + + for (let i = 0; i < totalFiles; i++) { + const file = files[i]; + const parts = file.webkitRelativePath.split('/'); + const parentPath = parts.slice(0, -1).join('/'); + const targetFolderId = folderMap.get(parentPath) || currentFolderId; + + const formData = new FormData(); + formData.append('folder_id', targetFolderId); + formData.append('file', file); + + try { + const response = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + cache: 'no-store', + headers: { + ...getAuthHeaders(), + 'Cache-Control': 'no-cache, no-store, must-revalidate' + } + }); + + uploadedCount++; + const percentComplete = (uploadedCount / totalFiles) * 100; + progressBar.style.width = percentComplete + '%'; + + if (response.ok) { + console.log(`Uploaded: ${file.webkitRelativePath}`); + } else { + console.error(`Error uploading ${file.webkitRelativePath}:`, await response.text()); + } + } catch (error) { + console.error(`Network error uploading ${file.webkitRelativePath}:`, error); + } + } + + // Finish up + window.ui.showNotification('Carpeta subida', `${uploadedCount} archivos subidos correctamente`); + + await new Promise(resolve => setTimeout(resolve, 800)); + + 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'; + uploadProgressDiv.style.display = 'none'; + }, 500); + }, + /** * Create a new folder * @param {string} name - Folder name @@ -116,6 +257,7 @@ const fileOps = { const response = await fetch('/api/folders', { method: 'POST', headers: { + ...getAuthHeaders(), 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate' }, @@ -162,6 +304,7 @@ const fileOps = { const response = await fetch(`/api/files/${fileId}/move`, { method: 'PUT', headers: { + ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -203,6 +346,7 @@ const fileOps = { const response = await fetch(`/api/folders/${folderId}/move`, { method: 'PUT', headers: { + ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -233,6 +377,53 @@ const fileOps = { } }, + /** + * Rename a file + * @param {string} fileId - File ID + * @param {string} newName - New file name + * @returns {Promise} - Success status + */ + async renameFile(fileId, newName) { + try { + console.log(`Renaming file ${fileId} to "${newName}"`); + + const response = await fetch(`/api/files/${fileId}/rename`, { + method: 'PUT', + headers: { + ...getAuthHeaders(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name: newName }) + }); + + console.log('Response status:', response.status); + + if (response.ok) { + window.ui.showNotification( + window.i18n ? window.i18n.t('notifications.file_renamed') : 'Archivo renombrado', + window.i18n ? window.i18n.t('notifications.file_renamed_to', { name: newName }) : `Archivo renombrado a "${newName}"` + ); + return true; + } else { + const errorText = await response.text(); + console.error('Error response:', errorText); + let errorMessage = 'Error desconocido'; + try { + const errorData = JSON.parse(errorText); + errorMessage = errorData.error || response.statusText; + } catch (e) { + errorMessage = errorText || response.statusText; + } + window.ui.showNotification('Error', `Error al renombrar el archivo: ${errorMessage}`); + return false; + } + } catch (error) { + console.error('Error renaming file:', error); + window.ui.showNotification('Error', 'Error al renombrar el archivo'); + return false; + } + }, + /** * Rename a folder * @param {string} folderId - Folder ID @@ -246,6 +437,7 @@ const fileOps = { const response = await fetch(`/api/folders/${folderId}/rename`, { method: 'PUT', headers: { + ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) @@ -287,14 +479,18 @@ const fileOps = { * @returns {Promise} - Success status */ async deleteFile(fileId, fileName) { - if (!confirm(`¿Estás seguro de que quieres mover a la papelera el archivo "${fileName}"?`)) { - return false; - } + const confirmed = await showConfirmDialog({ + title: window.i18n ? window.i18n.t('dialogs.confirm_delete') : 'Mover a papelera', + message: window.i18n ? window.i18n.t('dialogs.confirm_delete_file', { name: fileName }) : `¿Estás seguro de que quieres mover a la papelera el archivo "${fileName}"?`, + confirmText: window.i18n ? window.i18n.t('actions.delete') : 'Eliminar', + }); + if (!confirmed) return false; try { // Use the trash API endpoint const response = await fetch(`/api/trash/files/${fileId}`, { - method: 'DELETE' + method: 'DELETE', + headers: getAuthHeaders() }); if (response.ok) { @@ -304,7 +500,8 @@ const fileOps = { } else { // Fallback to direct deletion if trash fails const fallbackResponse = await fetch(`/api/files/${fileId}`, { - method: 'DELETE' + method: 'DELETE', + headers: getAuthHeaders() }); if (fallbackResponse.ok) { @@ -330,14 +527,18 @@ const fileOps = { * @returns {Promise} - Success status */ async deleteFolder(folderId, folderName) { - if (!confirm(`¿Estás seguro de que quieres mover a la papelera la carpeta "${folderName}" y todo su contenido?`)) { - return false; - } + const confirmed = await showConfirmDialog({ + title: window.i18n ? window.i18n.t('dialogs.confirm_delete') : 'Mover a papelera', + message: window.i18n ? window.i18n.t('dialogs.confirm_delete_folder', { name: folderName }) : `¿Estás seguro de que quieres mover a la papelera la carpeta "${folderName}" y todo su contenido?`, + confirmText: window.i18n ? window.i18n.t('actions.delete') : 'Eliminar', + }); + if (!confirmed) return false; try { // Use the trash API endpoint const response = await fetch(`/api/trash/folders/${folderId}`, { - method: 'DELETE' + method: 'DELETE', + headers: getAuthHeaders() }); if (response.ok) { @@ -352,7 +553,8 @@ const fileOps = { } else { // Fallback to direct deletion if trash fails const fallbackResponse = await fetch(`/api/folders/${folderId}`, { - method: 'DELETE' + method: 'DELETE', + headers: getAuthHeaders() }); if (fallbackResponse.ok) { @@ -382,7 +584,9 @@ const fileOps = { */ async getTrashItems() { try { - const response = await fetch('/api/trash'); + const response = await fetch('/api/trash', { + headers: getAuthHeaders() + }); if (response.ok) { return await response.json(); @@ -406,6 +610,7 @@ const fileOps = { const response = await fetch(`/api/trash/${trashId}/restore`, { method: 'POST', headers: { + ...getAuthHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({}) @@ -431,13 +636,17 @@ const fileOps = { * @returns {Promise} - Éxito de la operación */ async deletePermanently(trashId) { - if (!confirm('¿Estás seguro de que quieres eliminar permanentemente este elemento? Esta acción no se puede deshacer.')) { - return false; - } + const confirmed = await showConfirmDialog({ + title: window.i18n ? window.i18n.t('dialogs.confirm_permanent_delete') : 'Eliminar permanentemente', + message: window.i18n ? window.i18n.t('dialogs.confirm_permanent_delete_msg') : '¿Estás seguro de que quieres eliminar permanentemente este elemento? Esta acción no se puede deshacer.', + confirmText: window.i18n ? window.i18n.t('actions.delete_permanently') : 'Eliminar permanentemente', + }); + if (!confirmed) return false; try { const response = await fetch(`/api/trash/${trashId}`, { - method: 'DELETE' + method: 'DELETE', + headers: getAuthHeaders() }); if (response.ok) { @@ -459,14 +668,17 @@ const fileOps = { * @returns {Promise} - Éxito de la operación */ async emptyTrash() { - const confirmMsg = window.i18n ? window.i18n.t('trash.empty_confirm') : '¿Estás seguro de que quieres vaciar la papelera? Esta acción eliminará permanentemente todos los elementos.'; - if (!confirm(confirmMsg)) { - return false; - } + const confirmed = await showConfirmDialog({ + title: window.i18n ? window.i18n.t('dialogs.confirm_empty_trash') : 'Vaciar papelera', + message: window.i18n ? window.i18n.t('trash.empty_confirm') : '¿Estás seguro de que quieres vaciar la papelera? Esta acción eliminará permanentemente todos los elementos.', + confirmText: window.i18n ? window.i18n.t('actions.empty_trash') : 'Vaciar papelera', + }); + if (!confirmed) return false; try { const response = await fetch('/api/trash/empty', { - method: 'DELETE' + method: 'DELETE', + headers: getAuthHeaders() }); if (response.ok) { @@ -488,15 +700,28 @@ const fileOps = { * @param {string} fileId - ID del archivo * @param {string} fileName - Nombre del archivo */ - downloadFile(fileId, fileName) { - // Create a link and trigger download - const link = document.createElement('a'); - link.href = `/api/files/${fileId}`; - link.download = fileName; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + async downloadFile(fileId, fileName) { + try { + const response = await fetch(`/api/files/${fileId}`, { + headers: getAuthHeaders() + }); + if (response.ok) { + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } else { + window.ui.showNotification('Error', 'Error al descargar el archivo'); + } + } catch (error) { + console.error('Error downloading file:', error); + window.ui.showNotification('Error', 'Error al descargar el archivo'); + } }, /** @@ -509,15 +734,22 @@ const fileOps = { // Show notification to user window.ui.showNotification('Preparando descarga', 'Preparando la carpeta para descargar...'); - // Request the server to create a ZIP of the folder - // Since the API might not support this directly, we will simply download with zip parameter - const link = document.createElement('a'); - link.href = `/api/folders/${folderId}/download?format=zip`; - link.download = `${folderName}.zip`; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const response = await fetch(`/api/folders/${folderId}/download?format=zip`, { + headers: getAuthHeaders() + }); + if (response.ok) { + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${folderName}.zip`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } else { + window.ui.showNotification('Error', 'Error al descargar la carpeta'); + } } catch (error) { console.error('Error downloading folder:', error); window.ui.showNotification('Error', 'Error al descargar la carpeta'); diff --git a/static/js/i18n.js b/static/js/i18n.js index 4be868d9..ac23af8c 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -11,7 +11,8 @@ let currentLocale = (navigator.userLanguage && navigator.userLanguage.substring(0, 2)) || 'en'; -// Supported locales +// Supported locales (languages that have locale files on the server) +// When a locale file is not found, the system gracefully falls back to English const supportedLocales = ['en', 'es', 'zh', 'fa']; // Fallback to English if locale is not supported diff --git a/static/js/inlineViewer.js b/static/js/inlineViewer.js index a5c06482..7ca728b7 100644 --- a/static/js/inlineViewer.js +++ b/static/js/inlineViewer.js @@ -164,6 +164,12 @@ class InlineViewer { xhr.open('GET', `/api/files/${file.id}?inline=true`, true); xhr.responseType = 'blob'; + // Add auth header + const token = localStorage.getItem('oxicloud_token'); + if (token) { + xhr.setRequestHeader('Authorization', `Bearer ${token}`); + } + // Create a promise to handle the XHR const response = await new Promise((resolve, reject) => { xhr.onload = function() { @@ -288,14 +294,25 @@ class InlineViewer { } downloadFile(file) { - // Create a link and click it - const link = document.createElement('a'); - link.href = `/api/files/${file.id}`; - link.download = file.name; - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const token = localStorage.getItem('oxicloud_token'); + const headers = token ? { 'Authorization': `Bearer ${token}` } : {}; + + fetch(`/api/files/${file.id}`, { headers }) + .then(res => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.blob(); + }) + .then(blob => { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }) + .catch(err => console.error('Download error:', err)); } zoomImage(factor) { diff --git a/static/js/languageSelector.js b/static/js/languageSelector.js index 7ff3fcaa..a7efd777 100644 --- a/static/js/languageSelector.js +++ b/static/js/languageSelector.js @@ -4,12 +4,18 @@ */ // Language codes, names, and flag emojis -const languages = [ - { code: 'en', name: 'English', flag: '🇬🇧' }, - { code: 'es', name: 'Español', flag: '🇪🇸' }, - { code: 'zh', name: '中文', flag: '🇨🇳' }, - { code: 'fa', name: 'فارسی', flag: '🦁' } -]; +// Uses ALL_LANGUAGES from auth.js if available, otherwise fallback +function getAvailableLanguages() { + if (typeof ALL_LANGUAGES !== 'undefined') { + return ALL_LANGUAGES.map(l => ({ code: l.code, name: l.nativeName, flag: l.flag })); + } + return [ + { code: 'en', name: 'English', flag: '🇬🇧' }, + { code: 'es', name: 'Español', flag: '🇪🇸' }, + { code: 'zh', name: '中文', flag: '🇨🇳' }, + { code: 'fa', name: 'فارسی', flag: '🇮🇷' } + ]; +} // RTL languages const rtlLanguages = ['fa']; // ['fa', 'ar'] @@ -47,6 +53,7 @@ function createLanguageSelector(containerId = 'language-selector') { container.className = 'language-selector'; // Get current language + const languages = getAvailableLanguages(); const currentLocale = window.i18n ? window.i18n.getCurrentLocale() : 'en'; const currentLang = languages.find(l => l.code === currentLocale) || languages[0]; @@ -182,6 +189,7 @@ async function selectLanguage(langCode, container) { * Update the UI to reflect selected language */ function updateSelectedLanguage(langCode, container) { + const languages = getAvailableLanguages(); const lang = languages.find(l => l.code === langCode) || languages[0]; // Update toggle button text diff --git a/static/js/recent.js b/static/js/recent.js index 2f1a81df..3f89a1fb 100644 --- a/static/js/recent.js +++ b/static/js/recent.js @@ -117,8 +117,8 @@ const recent = {
`; - // Update breadcrumb for recents - window.ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.recent') : 'Recientes'); + // Update breadcrumb - just show Home + window.ui.updateBreadcrumb(''); // Show empty state if no recent files if (recentFiles.length === 0) { diff --git a/static/js/ui.js b/static/js/ui.js index 786f9451..91f44e18 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -21,17 +21,19 @@ const ui = {
Añadir a favoritos
-
- Renombrar -
-
- Mover a... -
Compartir
-
- Eliminar +
+
+ Renombrar +
+
+ Mover a... +
+
+
+ Eliminar
`; document.body.appendChild(folderMenu); @@ -49,33 +51,44 @@ const ui = {
Descargar
+
Añadir a favoritos
Compartir
-
- Mover a... +
+
+ Renombrar
-
- Eliminar +
+ Mover a... +
+
+
+ Eliminar
`; document.body.appendChild(fileMenu); } - // Rename dialog + // Rename dialog — modern if (!document.getElementById('rename-dialog')) { const renameDialog = document.createElement('div'); renameDialog.className = 'rename-dialog'; renameDialog.id = 'rename-dialog'; renameDialog.innerHTML = `
-
Renombrar carpeta
- +
+ + Renombrar +
+
+ +
- +
@@ -83,23 +96,27 @@ const ui = { document.body.appendChild(renameDialog); } - // Move dialog + // Move dialog — modern if (!document.getElementById('move-file-dialog')) { const moveDialog = document.createElement('div'); moveDialog.className = 'rename-dialog'; moveDialog.id = 'move-file-dialog'; moveDialog.innerHTML = `
-
Mover archivo
-

Selecciona la carpeta destino:

-
- -
- Raíz +
+ + Mover +
+
+

Selecciona la carpeta destino:

+
+
+ Raíz +
- +
@@ -114,7 +131,10 @@ const ui = { shareDialog.id = 'share-dialog'; shareDialog.innerHTML = `
@@ -206,7 +226,10 @@ const ui = { notificationDialog.id = 'notification-dialog'; notificationDialog.innerHTML = `
@@ -473,40 +496,132 @@ const ui = { if (iconElement.classList.contains('folder-icon')) { iconElement.innerHTML = ''; + return; } - else if (fileName.endsWith('.docx') || fileName.endsWith('.pdf') || fileName.endsWith('.txt') || fileName.endsWith('.xlsx')) { - iconElement.classList.add('doc-icon'); - iconElement.innerHTML = ''; - } - else if (fileName.endsWith('.jpg') || fileName.endsWith('.png') || fileName.endsWith('.gif') || fileName.endsWith('.jpeg')) { - iconElement.classList.add('image-icon'); - iconElement.innerHTML = ''; - } - else if (fileName.endsWith('.mp4') || fileName.endsWith('.avi') || fileName.endsWith('.mov') || fileName.endsWith('.mkv')) { - iconElement.classList.add('video-icon'); - iconElement.innerHTML = ''; - } - else { - const extension = fileName.split('.').pop().toLowerCase(); - if (['json', 'js', 'jsx', 'ts', 'tsx', 'html', 'css', 'scss', 'py', 'java', 'c', 'cpp', 'cs', 'php', 'rb', 'go', 'rs', 'swift', 'kt'].includes(extension)) { - iconElement.className = 'file-icon code-icon'; + const extension = fileName.includes('.') ? fileName.split('.').pop().toLowerCase() : ''; + + // Map extensions to icon types + const iconMap = { + // Documents + pdf: { cls: 'pdf-icon', fa: 'fas fa-file-pdf' }, + doc: { cls: 'doc-icon', fa: 'fas fa-file-word' }, + docx: { cls: 'doc-icon', fa: 'fas fa-file-word' }, + txt: { cls: 'doc-icon', fa: 'fas fa-file-alt' }, + rtf: { cls: 'doc-icon', fa: 'fas fa-file-alt' }, + odt: { cls: 'doc-icon', fa: 'fas fa-file-alt' }, + // Spreadsheets + xlsx: { cls: 'spreadsheet-icon' }, + xls: { cls: 'spreadsheet-icon' }, + csv: { cls: 'spreadsheet-icon' }, + ods: { cls: 'spreadsheet-icon' }, + // Presentations + pptx: { cls: 'presentation-icon' }, + ppt: { cls: 'presentation-icon' }, + odp: { cls: 'presentation-icon' }, + // Images + jpg: { cls: 'image-icon' }, + jpeg: { cls: 'image-icon' }, + png: { cls: 'image-icon' }, + gif: { cls: 'image-icon' }, + svg: { cls: 'image-icon' }, + webp: { cls: 'image-icon' }, + bmp: { cls: 'image-icon' }, + ico: { cls: 'image-icon' }, + // Videos + mp4: { cls: 'video-icon' }, + avi: { cls: 'video-icon' }, + mov: { cls: 'video-icon' }, + mkv: { cls: 'video-icon' }, + webm: { cls: 'video-icon' }, + flv: { cls: 'video-icon' }, + // Audio + mp3: { cls: 'audio-icon' }, + wav: { cls: 'audio-icon' }, + ogg: { cls: 'audio-icon' }, + flac: { cls: 'audio-icon' }, + aac: { cls: 'audio-icon' }, + m4a: { cls: 'audio-icon' }, + // Archives + zip: { cls: 'archive-icon' }, + rar: { cls: 'archive-icon' }, + '7z': { cls: 'archive-icon' }, + tar: { cls: 'archive-icon' }, + gz: { cls: 'archive-icon' }, + bz2: { cls: 'archive-icon' }, + // Installers + dmg: { cls: 'installer-icon' }, + exe: { cls: 'installer-icon' }, + msi: { cls: 'installer-icon' }, + deb: { cls: 'installer-icon' }, + rpm: { cls: 'installer-icon' }, + pkg: { cls: 'installer-icon' }, + app: { cls: 'installer-icon' }, + // Scripts + sh: { cls: 'script-icon', fa: 'fas fa-terminal' }, + bash: { cls: 'script-icon', fa: 'fas fa-terminal' }, + zsh: { cls: 'script-icon', fa: 'fas fa-terminal' }, + bat: { cls: 'script-icon', fa: 'fas fa-terminal' }, + ps1: { cls: 'script-icon', fa: 'fas fa-terminal' }, + // Code — each with sub-type + json: { cls: 'code-icon', sub: 'json-icon' }, + js: { cls: 'code-icon', sub: 'js-icon' }, + jsx: { cls: 'code-icon', sub: 'js-icon' }, + ts: { cls: 'code-icon', sub: 'ts-icon' }, + tsx: { cls: 'code-icon', sub: 'ts-icon' }, + html: { cls: 'code-icon', sub: 'html-icon' }, + htm: { cls: 'code-icon', sub: 'html-icon' }, + css: { cls: 'code-icon', sub: 'css-icon' }, + scss: { cls: 'code-icon', sub: 'css-icon' }, + py: { cls: 'code-icon', sub: 'py-icon' }, + rs: { cls: 'code-icon', sub: 'rust-icon' }, + go: { cls: 'code-icon', sub: 'go-icon' }, + java: { cls: 'code-icon', sub: 'java-icon' }, + c: { cls: 'code-icon', sub: 'c-icon' }, + cpp: { cls: 'code-icon', sub: 'c-icon' }, + cs: { cls: 'code-icon', sub: 'cs-icon' }, + php: { cls: 'code-icon', sub: 'php-icon' }, + rb: { cls: 'code-icon', sub: 'ruby-icon' }, + swift: { cls: 'code-icon', sub: 'swift-icon' }, + kt: { cls: 'code-icon', sub: 'kotlin-icon' }, + sql: { cls: 'code-icon', sub: 'sql-icon' }, + yaml: { cls: 'code-icon', sub: 'yaml-icon' }, + yml: { cls: 'code-icon', sub: 'yaml-icon' }, + toml: { cls: 'code-icon', sub: 'toml-icon' }, + xml: { cls: 'code-icon', sub: 'html-icon' }, + md: { cls: 'code-icon', sub: 'md-icon' }, + // Config + ini: { cls: 'config-icon', fa: 'fas fa-cog' }, + cfg: { cls: 'config-icon', fa: 'fas fa-cog' }, + conf: { cls: 'config-icon', fa: 'fas fa-cog' }, + env: { cls: 'config-icon', fa: 'fas fa-cog' }, + }; + + const mapping = iconMap[extension]; + if (mapping) { + iconElement.className = `file-icon ${mapping.cls}`; + if (mapping.cls === 'code-icon') { + // Code icons use pseudo-element lines iconElement.innerHTML = `
`; - - if (extension === 'json') { - iconElement.classList.add('json-icon'); - } else if (['js', 'jsx', 'ts', 'tsx'].includes(extension)) { - iconElement.classList.add('js-icon'); - } else if (extension === 'html') { - iconElement.classList.add('html-icon'); - } else if (['css', 'scss'].includes(extension)) { - iconElement.classList.add('css-icon'); - } else if (extension === 'py') { - iconElement.classList.add('py-icon'); + if (mapping.sub) iconElement.classList.add(mapping.sub); + } else { + // Types with pure CSS visuals — clear the + const pureCssTypes = ['image-icon','video-icon','spreadsheet-icon','presentation-icon','audio-icon','archive-icon','installer-icon']; + if (pureCssTypes.includes(mapping.cls)) { + iconElement.innerHTML = ''; + } else if (mapping.fa) { + // Types that keep the FA icon — update class + let iEl = iconElement.querySelector('i'); + if (!iEl) { + iEl = document.createElement('i'); + iconElement.innerHTML = ''; + iconElement.appendChild(iEl); + } + iEl.className = mapping.fa; } } } @@ -534,6 +649,8 @@ const ui = { folderGridElement.dataset.folderName = folder.name; folderGridElement.dataset.parentId = folder.parent_id || ""; folderGridElement.innerHTML = ` +
+
@@ -546,6 +663,10 @@ const ui = { folderGridElement.setAttribute('draggable', 'true'); folderGridElement.addEventListener('dragstart', (e) => { + if (!folderGridElement.classList.contains('selected')) { + e.preventDefault(); + return; + } e.dataTransfer.setData('text/plain', folder.id); e.dataTransfer.setData('application/oxicloud-folder', 'true'); folderGridElement.classList.add('dragging'); @@ -559,13 +680,36 @@ const ui = { }); } - // Click to navigate - folderGridElement.addEventListener('click', () => { + // Single click to select, double click to navigate + folderGridElement.addEventListener('click', (e) => { + if (e.target.closest('.file-card-more') || e.target.closest('.file-card-checkbox')) return; + toggleCardSelection(folderGridElement, e); + }); + + folderGridElement.addEventListener('dblclick', () => { window.app.currentPath = folder.id; this.updateBreadcrumb(folder.name); window.loadFiles(); }); + // Checkbox click + folderGridElement.querySelector('.file-card-checkbox').addEventListener('click', (e) => { + e.stopPropagation(); + toggleCardSelection(folderGridElement, e); + }); + + // More actions button + folderGridElement.querySelector('.file-card-more').addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + window.app.contextMenuTargetFolder = { + id: folder.id, + name: folder.name, + parent_id: folder.parent_id || "" + }; + showContextMenuAtElement(e.currentTarget, 'folder-context-menu'); + }); + // Context menu folderGridElement.addEventListener('contextmenu', (e) => { e.preventDefault(); @@ -766,6 +910,8 @@ const ui = { const fileGridElement = document.createElement('div'); fileGridElement.className = 'file-card'; fileGridElement.innerHTML = ` +
+
@@ -781,6 +927,10 @@ const ui = { fileGridElement.setAttribute('draggable', 'true'); fileGridElement.addEventListener('dragstart', (e) => { + if (!fileGridElement.classList.contains('selected')) { + e.preventDefault(); + return; + } e.dataTransfer.setData('text/plain', file.id); fileGridElement.classList.add('dragging'); }); @@ -792,8 +942,13 @@ const ui = { }); }); - // View or download on click - fileGridElement.addEventListener('click', () => { + // Single click = select, double click = open/download + fileGridElement.addEventListener('click', (e) => { + if (e.target.closest('.file-card-more') || e.target.closest('.file-card-checkbox')) return; + toggleCardSelection(fileGridElement, e); + }); + + fileGridElement.addEventListener('dblclick', () => { // Track this file access for recent files if (window.recent) { document.dispatchEvent(new CustomEvent('file-accessed', { @@ -804,22 +959,36 @@ const ui = { // Check if it's a viewable file type if ((file.mime_type && file.mime_type.startsWith('image/')) || (file.mime_type && file.mime_type === 'application/pdf')) { - // Open in the inline viewer if (window.inlineViewer) { window.inlineViewer.openFile(file); } else if (window.fileViewer) { - // Fallback to standard file viewer window.fileViewer.open(file); } else { - // No viewer available, download directly window.location.href = `/api/files/${file.id}`; } } else { - // For other file types, download directly window.location.href = `/api/files/${file.id}`; } }); + // Checkbox click + fileGridElement.querySelector('.file-card-checkbox').addEventListener('click', (e) => { + e.stopPropagation(); + toggleCardSelection(fileGridElement, e); + }); + + // More actions button + fileGridElement.querySelector('.file-card-more').addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + window.app.contextMenuTargetFile = { + id: file.id, + name: file.name, + folder_id: file.folder_id || "" + }; + showContextMenuAtElement(e.currentTarget, 'file-context-menu'); + }); + // Context menu fileGridElement.addEventListener('contextmenu', (e) => { e.preventDefault(); @@ -920,5 +1089,197 @@ const ui = { } }; +// --- Global helper functions for card interactions --- + +/** + * Toggle selection state of a file/folder card. + * Each click toggles that card independently (multi-select by default). + */ +function toggleCardSelection(card, event) { + card.classList.toggle('selected'); +} + +/** + * Show the context menu anchored next to a trigger element (the 3-dot button). + */ +function showContextMenuAtElement(triggerElement, menuId) { + // Hide any open menus first + document.querySelectorAll('.context-menu').forEach(m => m.style.display = 'none'); + + const menu = document.getElementById(menuId); + if (!menu) return; + + const rect = triggerElement.getBoundingClientRect(); + const menuWidth = 200; // approximate + + // Position below the trigger, aligned to the right edge + let left = rect.right - menuWidth + window.scrollX; + let top = rect.bottom + 4 + window.scrollY; + + // Keep inside viewport + if (left < 8) left = 8; + if (top + 300 > window.innerHeight + window.scrollY) { + top = rect.top - 4 + window.scrollY; // flip above if no room + } + + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + menu.style.display = 'block'; +} + +/** + * Rubber band (lasso) selection — click + drag on empty grid area + * to draw a rectangle and select all cards it touches. + */ +function initRubberBandSelection() { + // Create the visual rectangle element once + let selRect = document.getElementById('selection-rect'); + if (!selRect) { + selRect = document.createElement('div'); + selRect.id = 'selection-rect'; + selRect.className = 'selection-rect'; + document.body.appendChild(selRect); + } + + let active = false; + let startX = 0, startY = 0; + + // We listen on the whole files-container (covers grid + empty space) + const container = document.querySelector('.files-container') || document.getElementById('files-grid'); + if (!container) return; + + container.addEventListener('mousedown', (e) => { + // Only start if clicking empty area (not on a card, button, menu, input…) + if (e.button !== 0) return; // left click only + if (e.target.closest('.file-card') || e.target.closest('.context-menu') || + e.target.closest('.upload-dropdown') || e.target.closest('button') || + e.target.closest('input') || e.target.closest('.breadcrumb')) return; + + active = true; + startX = e.clientX; + startY = e.clientY; + + selRect.style.left = `${startX}px`; + selRect.style.top = `${startY}px`; + selRect.style.width = '0px'; + selRect.style.height = '0px'; + selRect.style.display = 'none'; // show only after a small movement + + e.preventDefault(); // prevent text selection + }); + + document.addEventListener('mousemove', (e) => { + if (!active) return; + + const curX = e.clientX; + const curY = e.clientY; + + const left = Math.min(startX, curX); + const top = Math.min(startY, curY); + const width = Math.abs(curX - startX); + const height = Math.abs(curY - startY); + + // Only show the rect after a small threshold to avoid flicker on click + if (width > 5 || height > 5) { + selRect.style.display = 'block'; + } + + selRect.style.left = `${left}px`; + selRect.style.top = `${top}px`; + selRect.style.width = `${width}px`; + selRect.style.height = `${height}px`; + + // Highlight cards that intersect with the rectangle + const rectBounds = { left, top, right: left + width, bottom: top + height }; + + document.querySelectorAll('#files-grid .file-card').forEach(card => { + const cardRect = card.getBoundingClientRect(); + const intersects = + cardRect.left < rectBounds.right && + cardRect.right > rectBounds.left && + cardRect.top < rectBounds.bottom && + cardRect.bottom > rectBounds.top; + + if (intersects) { + card.classList.add('selected'); + } else { + card.classList.remove('selected'); + } + }); + }); + + document.addEventListener('mouseup', () => { + if (!active) return; + active = false; + selRect.style.display = 'none'; + }); +} + +// Initialize rubber band once DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initRubberBandSelection); +} else { + initRubberBandSelection(); +} + +// Expose helpers globally +window.toggleCardSelection = toggleCardSelection; +window.showContextMenuAtElement = showContextMenuAtElement; +window.initRubberBandSelection = initRubberBandSelection; + +/** + * Show a modern confirm dialog (replaces native confirm()) + * @param {Object} options + * @param {string} options.title - Dialog title + * @param {string} options.message - Dialog message/body + * @param {string} [options.confirmText='Confirmar'] - Text for confirm button + * @param {string} [options.cancelText='Cancelar'] - Text for cancel button + * @param {boolean} [options.danger=false] - Use danger styling (red) + * @returns {Promise} true if confirmed, false if cancelled + */ +function showConfirmDialog({ title, message, confirmText, cancelText, danger = true } = {}) { + const ct = confirmText || (window.i18n ? window.i18n.t('actions.delete') : 'Eliminar'); + const cc = cancelText || (window.i18n ? window.i18n.t('actions.cancel') : 'Cancelar'); + const t = title || (window.i18n ? window.i18n.t('dialogs.confirm_title') : 'Confirmar acción'); + + return new Promise((resolve) => { + // Remove any previous confirm dialog + const prev = document.getElementById('confirm-dialog-overlay'); + if (prev) prev.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'confirm-dialog-overlay'; + overlay.className = 'confirm-dialog'; + overlay.innerHTML = ` +
+
+ +
+
${t}
+
${message || ''}
+
+ + +
+
+ `; + document.body.appendChild(overlay); + + // Force layout then show + requestAnimationFrame(() => { overlay.classList.add('active'); }); + + const cleanup = (result) => { + overlay.classList.remove('active'); + setTimeout(() => overlay.remove(), 200); + resolve(result); + }; + + overlay.querySelector('.confirm-dialog-cancel').addEventListener('click', () => cleanup(false)); + overlay.querySelector('.confirm-dialog-ok').addEventListener('click', () => cleanup(true)); + overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); }); + }); +} +window.showConfirmDialog = showConfirmDialog; + // Expose UI module globally window.ui = ui; diff --git a/static/locales/en.json b/static/locales/en.json index 75174688..ca789e94 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -14,6 +14,8 @@ "search": "Search files...", "new_folder": "New folder", "upload": "Upload", + "upload_files": "Upload files", + "upload_folder": "Upload folder", "rename": "Rename", "move": "Move to...", "move_to": "Move to", @@ -23,13 +25,23 @@ "cancel": "Cancel", "confirm": "Confirm", "share": "Share", + "favorite": "Add to favorites", + "unfavorite": "Remove from favorites", "copy": "Copy", "notify": "Notify", "send": "Send", "clear_recent": "Clear recent", "logout": "Log out", "create": "Create", - "search_btn": "Search" + "search_btn": "Search", + "close": "Close", + "delete_permanently": "Delete permanently", + "empty_trash": "Empty trash" + }, + "user_menu": { + "appearance": "Appearance", + "about": "About OxiCloud", + "about_description": "Cloud storage platform built with Rust & Clean Architecture. Fast, secure, and private." }, "share": { "dialogTitle": "Share Link", @@ -156,6 +168,7 @@ "size": "Size", "modified": "Modified", "no_files": "No files in this folder", + "loading": "Loading files…", "view_grid": "Grid view", "view_list": "List view", "file_types": { @@ -170,17 +183,28 @@ }, "dialogs": { "rename_folder": "Rename folder", + "rename_file": "Rename file", "new_name": "New name", "new_folder_title": "New folder", "folder_name": "Folder name", "folder_placeholder": "My folder", "rename_title": "Rename", "move_file": "Move file", - "select_destination": "Select destination folder", + "move_folder": "Move folder", + "select_destination": "Select destination folder:", "root": "Root", "delete_confirmation": "Are you sure you want to delete", "and_contents": "and all its contents", "no_undo": "This action cannot be undone", + "confirm_title": "Confirm action", + "confirm_delete": "Move to trash", + "confirm_delete_file": "Are you sure you want to move the file \"{{name}}\" to trash?", + "confirm_delete_folder": "Are you sure you want to move the folder \"{{name}}\" and all its contents to trash?", + "confirm_permanent_delete": "Delete permanently", + "confirm_permanent_delete_msg": "Are you sure you want to permanently delete this item? This action cannot be undone.", + "confirm_empty_trash": "Empty trash", + "confirm_delete_share": "Delete share link", + "confirm_delete_share_msg": "Are you sure you want to delete this shared link?", "share_file": "Share File", "existing_shares": "Existing Shares", "share_options": "Share Options", @@ -293,5 +317,16 @@ "accessed": "Accessed", "empty_state": "No recent files", "empty_hint": "Files you open will appear here" + }, + "notifications": { + "file_renamed": "File renamed", + "file_renamed_to": "File renamed to \"{{name}}\"", + "folder_renamed": "Folder renamed", + "folder_renamed_to": "Folder renamed to \"{{name}}\"", + "file_uploaded": "File uploaded", + "file_deleted": "File moved to trash", + "folder_deleted": "Folder moved to trash", + "item_deleted_permanently": "Item permanently deleted", + "trash_emptied": "Trash emptied successfully" } } \ No newline at end of file diff --git a/static/locales/es.json b/static/locales/es.json index 27c46c88..bdc90b3b 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -133,6 +133,8 @@ "search": "Buscar archivos...", "new_folder": "Nueva carpeta", "upload": "Subir", + "upload_files": "Subir archivos", + "upload_folder": "Subir carpeta", "rename": "Renombrar", "move": "Mover a...", "move_to": "Mover a", @@ -142,13 +144,23 @@ "cancel": "Cancelar", "confirm": "Confirmar", "share": "Compartir", + "favorite": "Añadir a favoritos", + "unfavorite": "Quitar de favoritos", "copy": "Copiar", "notify": "Notificar", "send": "Enviar", "clear_recent": "Limpiar recientes", "logout": "Cerrar sesión", "create": "Crear", - "search_btn": "Buscar" + "search_btn": "Buscar", + "close": "Cerrar", + "delete_permanently": "Eliminar permanentemente", + "empty_trash": "Vaciar papelera" + }, + "user_menu": { + "appearance": "Apariencia", + "about": "Acerca de OxiCloud", + "about_description": "Plataforma de almacenamiento en la nube creada con Rust y Arquitectura Limpia. Rápida, segura y privada." }, "files": { "name": "Nombre", @@ -156,6 +168,7 @@ "size": "Tamaño", "modified": "Modificado", "no_files": "No hay archivos en esta carpeta", + "loading": "Cargando archivos…", "view_grid": "Vista de cuadrícula", "view_list": "Vista de lista", "file_types": { @@ -170,17 +183,28 @@ }, "dialogs": { "rename_folder": "Renombrar carpeta", + "rename_file": "Renombrar archivo", "new_name": "Nuevo nombre", "new_folder_title": "Nueva carpeta", "folder_name": "Nombre de la carpeta", "folder_placeholder": "Mi carpeta", "rename_title": "Renombrar", "move_file": "Mover archivo", - "select_destination": "Selecciona la carpeta destino", + "move_folder": "Mover carpeta", + "select_destination": "Selecciona la carpeta destino:", "root": "Raíz", "delete_confirmation": "¿Estás seguro de que quieres eliminar", "and_contents": "y todo su contenido", "no_undo": "Esta acción no se puede deshacer", + "confirm_title": "Confirmar acción", + "confirm_delete": "Mover a papelera", + "confirm_delete_file": "¿Estás seguro de que quieres mover a la papelera el archivo \"{{name}}\"?", + "confirm_delete_folder": "¿Estás seguro de que quieres mover a la papelera la carpeta \"{{name}}\" y todo su contenido?", + "confirm_permanent_delete": "Eliminar permanentemente", + "confirm_permanent_delete_msg": "¿Estás seguro de que quieres eliminar permanentemente este elemento? Esta acción no se puede deshacer.", + "confirm_empty_trash": "Vaciar papelera", + "confirm_delete_share": "Eliminar enlace compartido", + "confirm_delete_share_msg": "¿Estás seguro de que quieres eliminar este enlace compartido?", "share_file": "Compartir Archivo", "existing_shares": "Compartidos Existentes", "share_options": "Opciones de Compartición", @@ -293,5 +317,16 @@ "accessed": "Accedido", "empty_state": "No hay archivos recientes", "empty_hint": "Los archivos que abras aparecerán aquí" + }, + "notifications": { + "file_renamed": "Archivo renombrado", + "file_renamed_to": "Archivo renombrado a \"{{name}}\"", + "folder_renamed": "Carpeta renombrada", + "folder_renamed_to": "Carpeta renombrada a \"{{name}}\"", + "file_uploaded": "Archivo subido", + "file_deleted": "Archivo movido a papelera", + "folder_deleted": "Carpeta movida a papelera", + "item_deleted_permanently": "Elemento eliminado permanentemente", + "trash_emptied": "Papelera vaciada correctamente" } } \ No newline at end of file diff --git a/static/locales/fa.json b/static/locales/fa.json index d03040e1..3e333a75 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -14,6 +14,8 @@ "search": "جست‌و‌جوی پرونده‌ها..", "new_folder": "پوشهٔ جدید", "upload": "بارگذاری", + "upload_files": "بارگذاری پرونده‌ها", + "upload_folder": "بارگذاری پوشه", "rename": "تغییر نام", "move": "انتقال به...", "move_to": "انتقال به", @@ -23,13 +25,21 @@ "cancel": "لغو", "confirm": "تأیید", "share": "هم‌رسانی", + "favorite": "افزودن به موردعلاقه‌ها", + "unfavorite": "حذف از موردعلاقه‌ها", "copy": "رونوشت", "notify": "آگاه‌سازی", "send": "ارسال", "clear_recent": "پاک‌کردن موارد اخیر", "logout": "خروج", "create": "ایجاد", - "search_btn": "جست‌و‌جو" + "search_btn": "جست‌و‌جو", + "close": "بستن" + }, + "user_menu": { + "appearance": "ظاهر", + "about": "درباره OxiCloud", + "about_description": "پلتفرم ذخیره‌سازی ابری ساخته شده با Rust و معماری تمیز. سریع، امن و خصوصی." }, "share": { "dialogTitle": "پیوند هم‌رسانی", diff --git a/static/locales/zh.json b/static/locales/zh.json index d8bb687c..8a0f7caf 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -14,6 +14,8 @@ "search": "搜索文件...", "new_folder": "新建文件夹", "upload": "上传", + "upload_files": "上传文件", + "upload_folder": "上传文件夹", "rename": "重命名", "move": "移动到...", "move_to": "移动到", @@ -23,13 +25,21 @@ "cancel": "取消", "confirm": "确认", "share": "共享", + "favorite": "添加到收藏", + "unfavorite": "取消收藏", "copy": "复制", "notify": "通知", "send": "发送", "clear_recent": "清除最近", "logout": "退出登录", "create": "创建", - "search_btn": "搜索" + "search_btn": "搜索", + "close": "关闭" + }, + "user_menu": { + "appearance": "外观", + "about": "关于 OxiCloud", + "about_description": "基于 Rust 和整洁架构构建的云存储平台。快速、安全、私密。" }, "share": { "dialogTitle": "共享链接", diff --git a/static/login.html b/static/login.html index 1dcfd042..b8e72c3f 100644 --- a/static/login.html +++ b/static/login.html @@ -28,42 +28,46 @@
OxiCloud
+ + +

Welcome to OxiCloud

Please select your language

+
- - - - - - - +
+ + +
+ + +