From 7069a54d8d491209a6c4d701b47504547375a1a1 Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Wed, 2 Apr 2025 03:43:44 +0200 Subject: [PATCH] adding favorite feature --- CLAUDE.md | 30 + db/schema.sql | 22 +- src/application/dtos/favorites_dto.rs | 21 + src/application/dtos/mod.rs | 1 + src/application/ports/favorites_ports.rs | 19 + src/application/ports/mod.rs | 3 +- src/application/services/favorites_service.rs | 191 +++++ src/application/services/mod.rs | 1 + src/common/di.rs | 12 + src/common/errors.rs | 2 + .../api/handlers/favorites_handler.rs | 118 +++ src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/routes.rs | 20 +- src/main.rs | 24 +- static/css/favorites.css | 85 +++ static/index.html | 2 + static/js/app.js | 120 +++- static/js/contextMenus.js | 54 ++ static/js/favorites.js | 669 ++++++++++++++++++ static/js/inlineViewer.js | 2 +- static/js/ui.js | 6 + 21 files changed, 1395 insertions(+), 8 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/application/dtos/favorites_dto.rs create mode 100644 src/application/ports/favorites_ports.rs create mode 100644 src/application/services/favorites_service.rs create mode 100644 src/interfaces/api/handlers/favorites_handler.rs create mode 100644 static/css/favorites.css create mode 100644 static/js/favorites.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..1ed13988 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,30 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands +- `cargo build` - Build the project +- `cargo run` - Run OxiCloud locally +- `cargo build --release` - Build with full optimization +- `cargo run --release` - Run optimized build +- `RUST_LOG=debug cargo run` - Run with detailed logging + +## Test Commands +- `cargo test` - Run all tests +- `cargo test ` - Run a specific test (e.g., `cargo test trash_service_test::tests::test_move_file_to_trash`) +- `cargo bench` - Run benchmarks with optimized settings + +## Lint Commands +- `cargo clippy` - Run linter with Rust's default Clippy lints +- `cargo fmt` - Format code according to Rust style guidelines + +## Code Style Guidelines +- Use async/await for asynchronous operations with tokio runtime +- Follow Rust naming conventions: snake_case for variables/functions, CamelCase for types +- Use thiserror for error handling with comprehensive error types +- Document all public functions with doc comments (/** ... */) +- Use the #[async_trait] attribute for async trait implementations +- Follow clean architecture principles with clear layer separation (domain, application, infrastructure, interfaces) +- Use Result types with specific error enums for error handling +- Keep functions small and focused on a single responsibility +- Prefer immutable variables (let) over mutable ones (let mut) when possible \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index 355013a2..56a867ee 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -69,6 +69,25 @@ CREATE TABLE IF NOT EXISTS auth.user_files ( CREATE INDEX IF NOT EXISTS idx_user_files_user_id ON auth.user_files(user_id); CREATE INDEX IF NOT EXISTS idx_user_files_file_id ON auth.user_files(file_id); +-- User favorites table for cross-device synchronization +CREATE TABLE IF NOT EXISTS auth.user_favorites ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(36) NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + item_id VARCHAR(255) NOT NULL, + item_type VARCHAR(10) NOT NULL, -- 'file' or 'folder' + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, item_id, item_type) +); + +-- Create indexes for efficient querying +CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON auth.user_favorites(user_id); +CREATE INDEX IF NOT EXISTS idx_user_favorites_item_id ON auth.user_favorites(item_id); +CREATE INDEX IF NOT EXISTS idx_user_favorites_type ON auth.user_favorites(item_type); +CREATE INDEX IF NOT EXISTS idx_user_favorites_created ON auth.user_favorites(created_at); + +-- Combined index for quick lookups by user and type +CREATE INDEX IF NOT EXISTS idx_user_favorites_user_type ON auth.user_favorites(user_id, item_type); + -- Create admin user (password: Admin123!) INSERT INTO auth.users ( id, @@ -105,4 +124,5 @@ INSERT INTO auth.users ( COMMENT ON TABLE auth.users IS 'Stores user account information'; COMMENT ON TABLE auth.sessions IS 'Stores user session information for refresh tokens'; -COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilization by users'; \ No newline at end of file +COMMENT ON TABLE auth.user_files IS 'Tracks file ownership and storage utilization by users'; +COMMENT ON TABLE auth.user_favorites IS 'Stores user favorite files and folders for cross-device synchronization'; \ No newline at end of file diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs new file mode 100644 index 00000000..04c6771d --- /dev/null +++ b/src/application/dtos/favorites_dto.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// DTO for favorites item +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FavoriteItemDto { + /// Unique identifier for the favorite entry + pub id: String, + + /// User ID who owns this favorite + pub user_id: String, + + /// ID of the favorited item (file or folder) + pub item_id: String, + + /// Type of the item ('file' or 'folder') + pub item_type: String, + + /// When the item was added to favorites + pub created_at: DateTime, +} \ No newline at end of file diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index ba8a85d6..f3bf665f 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -6,4 +6,5 @@ pub mod user_dto; pub mod trash_dto; pub mod search_dto; pub mod share_dto; +pub mod favorites_dto; diff --git a/src/application/ports/favorites_ports.rs b/src/application/ports/favorites_ports.rs new file mode 100644 index 00000000..139dbfbd --- /dev/null +++ b/src/application/ports/favorites_ports.rs @@ -0,0 +1,19 @@ +use async_trait::async_trait; +use crate::common::errors::Result; +use crate::application::dtos::favorites_dto::FavoriteItemDto; + +/// Defines operations for managing user favorites +#[async_trait] +pub trait FavoritesUseCase: Send + Sync { + /// Get all favorites for a user + async fn get_favorites(&self, user_id: &str) -> Result>; + + /// Add an item to user's favorites + async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>; + + /// Remove an item from user's favorites + async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; + + /// Check if an item is in user's favorites + async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; +} \ No newline at end of file diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index 20deb8fd..edf41e8a 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -4,4 +4,5 @@ pub mod file_ports; pub mod storage_ports; pub mod auth_ports; pub mod trash_ports; -pub mod share_ports; \ No newline at end of file +pub mod share_ports; +pub mod favorites_ports; \ No newline at end of file diff --git a/src/application/services/favorites_service.rs b/src/application/services/favorites_service.rs new file mode 100644 index 00000000..1f5b671c --- /dev/null +++ b/src/application/services/favorites_service.rs @@ -0,0 +1,191 @@ +use std::sync::Arc; +use async_trait::async_trait; +use sqlx::{PgPool, Row}; +use tracing::{info, error}; +use uuid::Uuid; +use crate::common::errors::{Result, DomainError, ErrorKind}; +use crate::application::ports::favorites_ports::FavoritesUseCase; +use crate::application::dtos::favorites_dto::FavoriteItemDto; + +/// Implementation of the FavoritesUseCase for managing user favorites +pub struct FavoritesService { + db_pool: Arc, +} + +impl FavoritesService { + /// Create a new FavoritesService with the given database pool + pub fn new(db_pool: Arc) -> Self { + Self { db_pool } + } +} + +#[async_trait] +impl FavoritesUseCase for FavoritesService { + /// Get all favorites for a user + async fn get_favorites(&self, user_id: &str) -> Result> { + info!("Getting favorites for user: {}", user_id); + + // Parse user ID as UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Execute raw query to avoid sqlx macros issues + let rows = sqlx::query( + r#" + SELECT + id::TEXT as "id", + user_id::TEXT as "user_id", + item_id as "item_id", + item_type as "item_type", + created_at as "created_at" + FROM auth.user_favorites + WHERE user_id = $1 + ORDER BY created_at DESC + "# + ) + .bind(user_uuid) + .fetch_all(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error fetching favorites: {}", e); + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to fetch favorites: {}", e) + ) + })?; + + // Map rows to DTOs + let mut favorites = Vec::with_capacity(rows.len()); + for row in rows { + favorites.push(FavoriteItemDto { + id: row.get("id"), + user_id: row.get("user_id"), + item_id: row.get("item_id"), + item_type: row.get("item_type"), + created_at: row.get("created_at"), + }); + } + + info!("Retrieved {} favorites for user {}", favorites.len(), user_id); + Ok(favorites) + } + + /// Add an item to user's favorites + async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> { + info!("Adding {} '{}' to favorites for user {}", item_type, item_id, user_id); + + // Validate item_type + if item_type != "file" && item_type != "folder" { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "Favorites", + "Item type must be 'file' or 'folder'" + )); + } + + // Parse user ID as UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Execute raw query to avoid sqlx macros issues + sqlx::query( + r#" + INSERT INTO auth.user_favorites (user_id, item_id, item_type) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, item_id, item_type) DO NOTHING + "# + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error adding favorite: {}", e); + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to add to favorites: {}", e) + ) + })?; + + info!("Successfully added {} '{}' to favorites for user {}", item_type, item_id, user_id); + Ok(()) + } + + /// Remove an item from user's favorites + async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { + info!("Removing {} '{}' from favorites for user {}", item_type, item_id, user_id); + + // Parse user ID as UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Execute raw query to avoid sqlx macros issues + let result = sqlx::query( + r#" + DELETE FROM auth.user_favorites + WHERE user_id = $1 AND item_id = $2 AND item_type = $3 + "# + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error removing favorite: {}", e); + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to remove from favorites: {}", e) + ) + })?; + + let removed = result.rows_affected() > 0; + info!( + "{} {} '{}' from favorites for user {}", + if removed { "Successfully removed" } else { "Did not find" }, + item_type, + item_id, + user_id + ); + + Ok(removed) + } + + /// Check if an item is in user's favorites + async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { + info!("Checking if {} '{}' is favorite for user {}", item_type, item_id, user_id); + + // Parse user ID as UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Execute raw query to avoid sqlx macros issues + let row = sqlx::query( + r#" + SELECT EXISTS ( + SELECT 1 FROM auth.user_favorites + WHERE user_id = $1 AND item_id = $2 AND item_type = $3 + ) AS "is_favorite" + "# + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .fetch_one(&*self.db_pool) + .await + .map_err(|e| { + error!("Database error checking favorite status: {}", e); + DomainError::new( + ErrorKind::InternalError, + "Favorites", + format!("Failed to check favorite status: {}", e) + ) + })?; + + // Get the boolean value from the row + let is_favorite: bool = row.try_get("is_favorite") + .unwrap_or(false); + + Ok(is_favorite) + } +} \ No newline at end of file diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index 5ae8464b..b09ed936 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -13,6 +13,7 @@ pub mod auth_application_service; pub mod trash_service; pub mod search_service; pub mod share_service; +pub mod favorites_service; #[cfg(test)] mod trash_service_test; diff --git a/src/common/di.rs b/src/common/di.rs index e7c732ac..81a2503f 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -21,6 +21,7 @@ use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::storage_mediator::{StorageMediator, FileSystemStorageMediator}; use crate::application::ports::inbound::{FileUseCase, FolderUseCase, SearchUseCase}; use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort}; +use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::application::ports::file_ports::{FileUploadUseCase, FileRetrievalUseCase, FileManagementUseCase, FileUseCaseFactory}; use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; use crate::infrastructure::repositories::{FileMetadataManager, FilePathResolver, FileFsReadRepository, FileFsWriteRepository}; @@ -240,6 +241,7 @@ impl AppServiceFactory { trash_service, search_service, share_service: None, // No share service by default + favorites_service: None // No favorites service by default } } } @@ -283,6 +285,7 @@ pub struct ApplicationServices { pub trash_service: Option>, pub search_service: Option>, pub share_service: Option>, + pub favorites_service: Option>, } /// Contenedor para servicios de autenticación @@ -303,6 +306,7 @@ pub struct AppState { pub auth_service: Option, pub trash_service: Option>, pub share_service: Option>, + pub favorites_service: Option>, } impl Default for AppState { @@ -772,6 +776,7 @@ impl Default for AppState { trash_service: None, // No trash service in minimal mode search_service: Some(Arc::new(DummySearchUseCase) as Arc), share_service: None, // No share service in minimal mode + favorites_service: None, // No favorites service in minimal mode }; // Return a minimal app state @@ -783,6 +788,7 @@ impl Default for AppState { auth_service: None, trash_service: None, share_service: None, + favorites_service: None, } } } @@ -801,6 +807,7 @@ impl AppState { auth_service: None, trash_service: None, share_service: None, + favorites_service: None, } } @@ -823,4 +830,9 @@ impl AppState { self.share_service = Some(share_service); self } + + pub fn with_favorites_service(mut self, favorites_service: Arc) -> Self { + self.favorites_service = Some(favorites_service); + self + } } \ No newline at end of file diff --git a/src/common/errors.rs b/src/common/errors.rs index 3238e77c..2e0b0305 100644 --- a/src/common/errors.rs +++ b/src/common/errors.rs @@ -238,6 +238,8 @@ macro_rules! impl_from_error { // Implementación para errores estándar comunes impl_from_error!(std::io::Error, "IO"); impl_from_error!(serde_json::Error, "Serialization"); +impl_from_error!(sqlx::Error, "Database"); +impl_from_error!(uuid::Error, "UUID"); // Error para capas HTTP/API #[derive(Debug)] diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs new file mode 100644 index 00000000..a091c57c --- /dev/null +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use tracing::{error, info}; +use uuid::Uuid; + +use crate::application::ports::favorites_ports::FavoritesUseCase; +use crate::application::dtos::favorites_dto::FavoriteItemDto; +use crate::common::errors::{Result, DomainError, ErrorKind}; + +/// Handler for favorite-related API endpoints +pub async fn get_favorites( + State(favorites_service): State>, +) -> impl IntoResponse { + // For demo purposes, we're using a fixed user ID + let user_id = "00000000-0000-0000-0000-000000000000"; + + match favorites_service.get_favorites(user_id).await { + Ok(favorites) => { + info!("Retrieved {} favorites for user", favorites.len()); + (StatusCode::OK, Json(serde_json::json!(favorites))).into_response() + }, + Err(err) => { + error!("Error retrieving favorites: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to retrieve favorites: {}", err) + })) + ).into_response() + } + } +} + +/// Add an item to user's favorites +pub async fn add_favorite( + State(favorites_service): State>, + Path((item_type, item_id)): Path<(String, String)>, +) -> impl IntoResponse { + // For demo purposes, we're using a fixed user ID + let user_id = "00000000-0000-0000-0000-000000000000"; + + // Validate item_type + if item_type != "file" && item_type != "folder" { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Item type must be 'file' or 'folder'" + })) + ); + } + + match favorites_service.add_to_favorites(user_id, &item_id, &item_type).await { + Ok(_) => { + info!("Added {} '{}' to favorites", item_type, item_id); + ( + StatusCode::CREATED, + Json(serde_json::json!({ + "message": "Item added to favorites" + })) + ) + }, + Err(err) => { + error!("Error adding to favorites: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to add to favorites: {}", err) + })) + ) + } + } +} + +/// Remove an item from user's favorites +pub async fn remove_favorite( + State(favorites_service): State>, + Path((item_type, item_id)): Path<(String, String)>, +) -> impl IntoResponse { + // For demo purposes, we're using a fixed user ID + let user_id = "00000000-0000-0000-0000-000000000000"; + + match favorites_service.remove_from_favorites(user_id, &item_id, &item_type).await { + Ok(removed) => { + if removed { + info!("Removed {} '{}' from favorites", item_type, item_id); + ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Item removed from favorites" + })) + ) + } else { + info!("Item {} '{}' was not in favorites", item_type, item_id); + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "message": "Item was not in favorites" + })) + ) + } + }, + Err(err) => { + error!("Error removing from favorites: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Failed to remove from favorites: {}", err) + })) + ) + } + } +} \ No newline at end of file diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index fcaf2dc7..c19e27e9 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -6,6 +6,7 @@ pub mod auth_handler; pub mod trash_handler; pub mod search_handler; pub mod share_handler; +pub mod favorites_handler; /// Tipo de resultado para controladores de API pub type ApiResult = Result; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 5d15456e..91bfa273 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -25,6 +25,7 @@ use crate::application::services::batch_operations::BatchOperationService; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::inbound::SearchUseCase; use crate::application::ports::share_ports::ShareUseCase; +use crate::application::ports::favorites_ports::FavoritesUseCase; use crate::interfaces::api::handlers::folder_handler::FolderHandler; use crate::interfaces::api::handlers::file_handler::FileHandler; @@ -43,6 +44,7 @@ pub fn create_api_routes( trash_service: Option>, search_service: Option>, share_service: Option>, + favorites_service: Option>, ) -> Router { // Create a simplified AppState for the trash view // Setup required components for repository construction @@ -106,11 +108,13 @@ pub fn create_api_routes( trash_service: trash_service.clone(), // Include the trash service here too for consistency search_service: search_service.clone(), // Include the search service share_service: share_service.clone(), // Include the share service + favorites_service: favorites_service.clone(), // Include the favorites service }, db_pool: None, auth_service: None, trash_service: trash_service.clone(), // This is the important part - include the trash service - share_service: share_service.clone() // Include the share service for routes + share_service: share_service.clone(), // Include the share service for routes + favorites_service: favorites_service.clone() // Include the favorites service for routes }; // Inicializar el servicio de operaciones por lotes let batch_service = Arc::new(BatchOperationService::default( @@ -324,6 +328,19 @@ pub fn create_api_routes( }; // Create a router without the i18n routes + // Create routes for favorites if the service is available + let favorites_router = if let Some(favorites_service) = favorites_service.clone() { + use crate::interfaces::api::handlers::favorites_handler; + + Router::new() + .route("/", get(favorites_handler::get_favorites)) + .route("/{item_type}/{item_id}", post(favorites_handler::add_favorite)) + .route("/{item_type}/{item_id}", delete(favorites_handler::remove_favorite)) + .with_state(favorites_service.clone()) + } else { + Router::new() + }; + let mut router = Router::new() .nest("/folders", folders_router) .nest("/files", files_router) @@ -331,6 +348,7 @@ pub fn create_api_routes( .nest("/search", search_router) .nest("/shares", share_router) .nest("/s", public_share_router) + .nest("/favorites", favorites_router) ; // Store the share service in app_state for future use diff --git a/src/main.rs b/src/main.rs index 44f57dfe..ccd92174 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ use application::services::file_service::FileService; use application::services::i18n_application_service::I18nApplicationService; use application::services::storage_mediator::FileSystemStorageMediator; use application::services::share_service::ShareService; +use application::services::favorites_service::FavoritesService; use domain::services::path_service::PathService; use infrastructure::repositories::folder_fs_repository::FolderFsRepository; use infrastructure::repositories::file_fs_repository::FileFsRepository; @@ -614,6 +615,21 @@ async fn main() -> Result<(), Box> { None }; + // Initialize favorites service if database is available + let favorites_service: Option> = + if let Some(ref pool) = db_pool { + // Create a new favorites service with the database pool + let favorites_service = Arc::new(FavoritesService::new( + pool.clone() + )); + + tracing::info!("Favorites service initialized successfully"); + Some(favorites_service) + } else { + tracing::info!("Favorites service is disabled (requires database connection)"); + None + }; + let application_services = common::di::ApplicationServices { folder_service: folder_service.clone(), file_service: file_service.clone(), @@ -625,6 +641,7 @@ async fn main() -> Result<(), Box> { trash_service: trash_service.clone(), search_service: search_service.clone(), share_service: share_service.clone(), + favorites_service: favorites_service.clone(), }; // Create the AppState without Arc first @@ -645,11 +662,16 @@ async fn main() -> Result<(), Box> { app_state = app_state.with_auth_services(services); } + // Add favorites service if available + if let Some(service) = favorites_service.clone() { + app_state = app_state.with_favorites_service(service); + } + // Wrap in Arc after all modifications let app_state = Arc::new(app_state); // Build application router - let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service), trash_service, search_service, share_service); + let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service), trash_service, search_service, share_service, favorites_service); let web_routes = create_web_routes(); // Build the app router diff --git a/static/css/favorites.css b/static/css/favorites.css new file mode 100644 index 00000000..f3ef4d2c --- /dev/null +++ b/static/css/favorites.css @@ -0,0 +1,85 @@ +/* Estilos para la funcionalidad de favoritos */ + +/* Indicador de favorito */ +.favorite-indicator { + position: absolute; + top: 10px; + right: 10px; + width: 25px; + height: 25px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #ccc; + cursor: pointer; + z-index: 5; + transition: all 0.2s ease; +} + +.favorite-indicator:hover { + transform: scale(1.1); +} + +.favorite-indicator.active { + color: #ffc107; + text-shadow: 0 0 5px rgba(255, 193, 7, 0.5); +} + +/* Estilos para los elementos de la vista de favoritos */ +.favorite-item { + position: relative; +} + +/* Ajustes para la vista de cuadrícula */ +.file-card.favorite-item { + border-left: 3px solid #ffc107; +} + +/* Ajustes para la vista de lista */ +.file-item.favorite-item { + position: relative; + grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px; +} + +.file-item.favorite-item .favorite-indicator { + position: relative; + top: 0; + right: 0; + width: 30px; + height: 30px; +} + +/* Estilos para el estado vacío específico de favoritos */ +.favorites-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 50px 20px; + text-align: center; + color: #6c757d; +} + +.favorites-empty-state i { + font-size: 48px; + color: #ffc107; + margin-bottom: 20px; + opacity: 0.6; +} + +.favorites-empty-state p { + margin-bottom: 10px; + max-width: 400px; +} + +/* Animación para la estrella de favorito */ +@keyframes favorite-pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.2); } + 100% { transform: scale(1); } +} + +.favorite-indicator.active { + animation: favorite-pulse 0.3s ease; +} \ No newline at end of file diff --git a/static/index.html b/static/index.html index 38297f87..6552212e 100644 --- a/static/index.html +++ b/static/index.html @@ -8,6 +8,7 @@ + @@ -20,6 +21,7 @@ + diff --git a/static/js/app.js b/static/js/app.js index 6058d304..29f5b31d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -14,7 +14,8 @@ const app = { moveDialogMode: 'file', // Move dialog mode: 'file' or 'folder' isTrashView: false, // Whether we're in trash view isSharedView: false, // Whether we're in shared view - currentSection: 'files', // Current section: 'files', 'trash' or 'shared' + isFavoritesView: false, // Whether we're in favorites view + currentSection: 'files', // Current section: 'files', 'trash', 'shared' or 'favorites' isSearchMode: false, // Whether we're in search mode // File sharing related properties shareDialogItem: null, // Item being shared in share dialog @@ -61,8 +62,24 @@ function initApp() { console.log('Inline viewer is available'); } else { console.warn('Inline viewer not initialized yet, will initialize it now'); - // Create inline viewer if not already created - window.inlineViewer = new InlineViewer(); + try { + // Create inline viewer if not already created and if the class exists + if (typeof InlineViewer !== 'undefined') { + window.inlineViewer = new InlineViewer(); + } else { + console.warn('InlineViewer class is not defined, skipping initialization'); + } + } catch (e) { + console.error('Error initializing inline viewer:', e); + } + } + + // Initialize favorites module if available + if (window.favorites && window.favorites.init) { + console.log('Initializing favorites module'); + window.favorites.init(); + } else { + console.warn('Favorites module not available or not initializable'); } // Wait for translations to load before checking authentication @@ -182,6 +199,13 @@ function setupEventListeners() { return; } + // Check if this is the favorites item + if (item.querySelector('span').getAttribute('data-i18n') === 'nav.favorites') { + // Switch to favorites view + switchToFavoritesView(); + return; + } + // Check if this is the trash item if (item === elements.trashBtn) { // Hide shared view if active @@ -735,6 +759,7 @@ function switchToFilesView() { // Reset view flags app.isTrashView = false; app.isSharedView = false; + app.isFavoritesView = false; app.currentSection = 'files'; // Update UI @@ -816,9 +841,98 @@ function switchToFilesView() { loadFiles(); } +/** + * Switch to the favorites view + */ +function switchToFavoritesView() { + // Hide other views + app.isTrashView = false; + app.isSharedView = false; + + // Set favorites view as active + app.isFavoritesView = true; + app.currentSection = 'favorites'; + + // Remove active class from all nav items + elements.navItems.forEach(navItem => navItem.classList.remove('active')); + + // Find favorites nav item and make it active + const favoritesNavItem = document.querySelector('.nav-item:nth-child(4)'); + if (favoritesNavItem) { + favoritesNavItem.classList.add('active'); + } + + // Update UI + 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'); + + // Hide shared view if it exists + if (window.sharedView) { + window.sharedView.hide(); + } + + // Configure actions bar for favorites view + elements.actionsBar.innerHTML = ` +
+ +
+
+ + +
+ `; + elements.actionsBar.style.display = 'flex'; + + // Restore view toggle event listeners + document.getElementById('grid-view-btn').addEventListener('click', ui.switchToGridView); + document.getElementById('list-view-btn').addEventListener('click', ui.switchToListView); + + // Update cached elements + elements.gridViewBtn = document.getElementById('grid-view-btn'); + elements.listViewBtn = document.getElementById('list-view-btn'); + + // Show standard files containers + const filesGrid = document.getElementById('files-grid'); + const filesListView = document.getElementById('files-list-view'); + + if (filesGrid) { + filesGrid.style.display = app.currentView === 'grid' ? 'grid' : 'none'; + } + + if (filesListView) { + filesListView.style.display = app.currentView === 'list' ? 'block' : 'none'; + } + + // Check if favorites module is initialized + if (window.favorites) { + // Display favorites + window.favorites.displayFavorites(); + } else { + console.error('Favorites module not loaded or initialized'); + + // Show error in UI + const filesGrid = document.getElementById('files-grid'); + if (filesGrid) { + filesGrid.innerHTML = ` +
+ +

Error al cargar el módulo de favoritos

+
+ `; + } + } +} + // Expose view switching functions globally window.switchToFilesView = switchToFilesView; window.switchToSharedView = switchToSharedView; +window.switchToFavoritesView = switchToFavoritesView; /** * Check if user is authenticated and load user's home folder diff --git a/static/js/contextMenus.js b/static/js/contextMenus.js index f957d6a3..f8f66b6f 100644 --- a/static/js/contextMenus.js +++ b/static/js/contextMenus.js @@ -20,6 +20,33 @@ const contextMenus = { window.ui.closeContextMenu(); }); + document.getElementById('favorite-folder-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFolder) { + const folder = window.app.contextMenuTargetFolder; + + // Check if folder is already in favorites to toggle + if (window.favorites && window.favorites.isFavorite(folder.id, 'folder')) { + // Remove from favorites + window.favorites.removeFromFavorites(folder.id, 'folder'); + // Update menu item text + document.getElementById('favorite-folder-option').querySelector('span').textContent = + window.i18n ? window.i18n.t('actions.favorite') : 'Añadir a favoritos'; + } else { + // Add to favorites + window.favorites.addToFavorites( + folder.id, + folder.name, + 'folder', + folder.parent_id + ); + // Update menu item text + document.getElementById('favorite-folder-option').querySelector('span').textContent = + window.i18n ? window.i18n.t('actions.unfavorite') : 'Quitar de favoritos'; + } + } + window.ui.closeContextMenu(); + }); + document.getElementById('rename-folder-option').addEventListener('click', () => { if (window.app.contextMenuTargetFolder) { this.showRenameDialog(window.app.contextMenuTargetFolder); @@ -62,6 +89,33 @@ const contextMenus = { window.ui.closeFileContextMenu(); }); + document.getElementById('favorite-file-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFile) { + const file = window.app.contextMenuTargetFile; + + // Check if file is already in favorites to toggle + if (window.favorites && window.favorites.isFavorite(file.id, 'file')) { + // Remove from favorites + window.favorites.removeFromFavorites(file.id, 'file'); + // Update menu item text + document.getElementById('favorite-file-option').querySelector('span').textContent = + window.i18n ? window.i18n.t('actions.favorite') : 'Añadir a favoritos'; + } else { + // Add to favorites + window.favorites.addToFavorites( + file.id, + file.name, + 'file', + file.folder_id + ); + // Update menu item text + document.getElementById('favorite-file-option').querySelector('span').textContent = + window.i18n ? window.i18n.t('actions.unfavorite') : 'Quitar de favoritos'; + } + } + window.ui.closeFileContextMenu(); + }); + document.getElementById('move-file-option').addEventListener('click', () => { if (window.app.contextMenuTargetFile) { this.showMoveDialog(window.app.contextMenuTargetFile, 'file'); diff --git a/static/js/favorites.js b/static/js/favorites.js new file mode 100644 index 00000000..0478baf3 --- /dev/null +++ b/static/js/favorites.js @@ -0,0 +1,669 @@ +/** + * OxiCloud - Favorites Module + * This file handles favoriting files and folders, persisting favorites, and displaying favorite items + */ + +// Favorites Module +const favorites = { + // Key for storing favorites in localStorage + STORAGE_KEY: 'oxicloud_favorites', + + // Flag to indicate if backend API is available + backendApiAvailable: false, + + /** + * Initialize favorites module + */ + init() { + console.log('Initializing favorites module'); + this.loadFavorites(); + + // Check if backend favorites API is available + this.checkBackendAvailability(); + }, + + /** + * Check if backend favorites API is available + */ + async checkBackendAvailability() { + try { + const response = await fetch('/api/favorites', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('oxicloud_token')}` + } + }); + + this.backendApiAvailable = response.ok; + console.log(`Backend favorites API ${this.backendApiAvailable ? 'is' : 'is not'} available`); + + // If backend API is available, sync local favorites with server + if (this.backendApiAvailable) { + this.syncWithServer(); + } + } catch (error) { + console.warn('Error checking backend favorites API availability:', error); + this.backendApiAvailable = false; + } + }, + + /** + * Sync local favorites with server + */ + async syncWithServer() { + try { + // Get server favorites + const response = await fetch('/api/favorites', { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('oxicloud_token')}` + } + }); + + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); + } + + const serverFavorites = await response.json(); + const localFavorites = this.loadFavorites(); + + console.log('Syncing favorites with server', { + serverCount: serverFavorites.length, + localCount: localFavorites.length + }); + + // Create a map of server favorites for quick lookup + const serverFavoritesMap = new Map(); + serverFavorites.forEach(item => { + serverFavoritesMap.set(`${item.item_type}:${item.item_id}`, item); + }); + + // Add local favorites that aren't on server + for (const localItem of localFavorites) { + const key = `${localItem.type}:${localItem.id}`; + if (!serverFavoritesMap.has(key)) { + console.log(`Adding local favorite to server: ${key}`); + await this.addToServerFavorites(localItem.id, localItem.type); + } + } + + // Store server favorites locally (complete sync) + const mergedFavorites = serverFavorites.map(item => ({ + id: item.item_id, + name: '', // Name will be populated when viewing favorites + type: item.item_type, + parentId: null, // Will be determined when viewing + dateAdded: item.created_at + })); + + this.saveFavorites(mergedFavorites); + console.log('Favorites sync completed'); + } catch (error) { + console.error('Error syncing favorites with server:', error); + } + }, + + /** + * Load favorites from localStorage + * @returns {Array} Array of favorite items + */ + loadFavorites() { + try { + const stored = localStorage.getItem(this.STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch (error) { + console.error('Error loading favorites:', error); + return []; + } + }, + + /** + * Save favorites to localStorage + * @param {Array} favorites - Array of favorite items to save + */ + saveFavorites(favorites) { + try { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(favorites)); + } catch (error) { + console.error('Error saving favorites:', error); + } + }, + + /** + * Add a favorite to the server + * @param {string} id - Item ID + * @param {string} type - 'file' or 'folder' + */ + async addToServerFavorites(id, type) { + try { + const response = await fetch(`/api/favorites/${type}/${id}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('oxicloud_token')}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); + } + + return true; + } catch (error) { + console.error('Error adding favorite to server:', error); + return false; + } + }, + + /** + * Remove a favorite from the server + * @param {string} id - Item ID + * @param {string} type - 'file' or 'folder' + */ + async removeFromServerFavorites(id, type) { + try { + const response = await fetch(`/api/favorites/${type}/${id}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('oxicloud_token')}` + } + }); + + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); + } + + return true; + } catch (error) { + console.error('Error removing favorite from server:', error); + return false; + } + }, + + /** + * Add an item to favorites + * @param {string} id - Item ID + * @param {string} name - Item name + * @param {string} type - 'file' or 'folder' + * @param {string} parentId - Parent folder ID (or null for root items) + * @returns {boolean} Success status + */ + async addToFavorites(id, name, type, parentId) { + try { + const favorites = this.loadFavorites(); + + // Check if already in favorites + if (favorites.some(item => item.id === id && item.type === type)) { + console.log(`Item ${id} already in favorites`); + return false; + } + + // Add to favorites + favorites.push({ + id, + name, + type, + parentId: parentId || null, + dateAdded: new Date().toISOString() + }); + + // Save updated favorites locally + this.saveFavorites(favorites); + + // If backend API is available, sync with server + if (this.backendApiAvailable) { + await this.addToServerFavorites(id, type); + } + + // Show success notification + window.ui.showNotification( + 'Añadido a favoritos', + `"${name}" añadido a favoritos` + ); + + return true; + } catch (error) { + console.error('Error adding to favorites:', error); + return false; + } + }, + + /** + * Remove an item from favorites + * @param {string} id - Item ID + * @param {string} type - 'file' or 'folder' + * @returns {boolean} Success status + */ + async removeFromFavorites(id, type) { + try { + let favorites = this.loadFavorites(); + const initialLength = favorites.length; + + // Find the item to get its name for notification + const item = favorites.find(item => item.id === id && item.type === type); + + // Filter out the item + favorites = favorites.filter(item => !(item.id === id && item.type === type)); + + // Save updated favorites locally + this.saveFavorites(favorites); + + // If backend API is available, sync with server + if (this.backendApiAvailable) { + await this.removeFromServerFavorites(id, type); + } + + // Check if anything was removed + if (favorites.length < initialLength) { + // Show success notification if item was found + if (item) { + window.ui.showNotification( + 'Eliminado de favoritos', + `"${item.name}" eliminado de favoritos` + ); + } + return true; + } + + return false; + } catch (error) { + console.error('Error removing from favorites:', error); + return false; + } + }, + + /** + * Check if an item is in favorites + * @param {string} id - Item ID + * @param {string} type - 'file' or 'folder' + * @returns {boolean} True if item is in favorites + */ + isFavorite(id, type) { + const favorites = this.loadFavorites(); + return favorites.some(item => item.id === id && item.type === type); + }, + + /** + * Load and display favorite items in the UI + */ + async displayFavorites() { + try { + const favorites = this.loadFavorites(); + + // Clear existing content + const filesGrid = document.getElementById('files-grid'); + const filesListView = document.getElementById('files-list-view'); + + filesGrid.innerHTML = ''; + filesListView.innerHTML = ` +
+
Nombre
+
Tipo
+
Tamaño
+
Modificado
+
+ `; + + // Update breadcrumb for favorites + window.ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.favorites') : 'Favoritos'); + + // Show empty state if no favorites + if (favorites.length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + emptyState.innerHTML = ` + +

${window.i18n ? window.i18n.t('favorites.empty_state') : 'No hay elementos favoritos'}

+

${window.i18n ? window.i18n.t('favorites.empty_hint') : 'Para marcar como favorito, haz clic derecho en cualquier archivo o carpeta'}

+ `; + filesGrid.appendChild(emptyState); + return; + } + + // Load details for each favorite item + let loadedItems = 0; + const totalItems = favorites.length; + + // Process each favorite item + for (const favorite of favorites) { + try { + if (favorite.type === 'folder') { + await this.loadFolderDetails(favorite, filesGrid, filesListView); + } else { + await this.loadFileDetails(favorite, filesGrid, filesListView); + } + } catch (error) { + console.error(`Error loading favorite ${favorite.type} ${favorite.id}:`, error); + } + + // Update progress - could be used for loading indicator + loadedItems++; + console.log(`Loaded ${loadedItems}/${totalItems} favorite items`); + } + + // Update file icons + window.ui.updateFileIcons(); + + } catch (error) { + console.error('Error displaying favorites:', error); + window.ui.showNotification('Error', 'Error al cargar elementos favoritos'); + } + }, + + /** + * Load folder details and add to view + * @param {Object} favorite - Favorite folder item + * @param {HTMLElement} filesGrid - Grid view container + * @param {HTMLElement} filesListView - List view container + */ + async loadFolderDetails(favorite, filesGrid, filesListView) { + try { + const response = await fetch(`/api/folders/${favorite.id}`); + + if (response.ok) { + const folder = await response.json(); + + // Create UI element with favorite indicator + this.createFavoriteFolderElement(folder, filesGrid, filesListView); + } else if (response.status === 404) { + // Folder not found, might be deleted + console.log(`Favorite folder ${favorite.id} not found, removing from favorites`); + this.removeFromFavorites(favorite.id, 'folder'); + } else { + console.error(`Error loading folder ${favorite.id}:`, response.statusText); + } + } catch (error) { + console.error(`Error loading folder details for ${favorite.id}:`, error); + } + }, + + /** + * Load file details and add to view + * @param {Object} favorite - Favorite file item + * @param {HTMLElement} filesGrid - Grid view container + * @param {HTMLElement} filesListView - List view container + */ + async loadFileDetails(favorite, filesGrid, filesListView) { + try { + const response = await fetch(`/api/files/${favorite.id}`); + + if (response.ok) { + // Check if the response is JSON or binary data like a PDF + const contentType = response.headers.get('content-type'); + + if (contentType && contentType.includes('application/json')) { + const file = await response.json(); + + // Create UI element with favorite indicator + this.createFavoriteFileElement(file, filesGrid, filesListView); + } else { + // For non-JSON responses (like PDFs or other binary files) + // Create a simplified file element with minimal information + const file = { + id: favorite.id, + name: favorite.name || `File ${favorite.id}`, + mime_type: contentType || 'application/octet-stream', + size: 0, // We don't know the size from this response + modified_at: Math.floor(Date.now() / 1000) // Current time in seconds + }; + + this.createFavoriteFileElement(file, filesGrid, filesListView); + } + } else if (response.status === 404) { + // File not found, might be deleted + console.log(`Favorite file ${favorite.id} not found, removing from favorites`); + this.removeFromFavorites(favorite.id, 'file'); + } else { + console.error(`Error loading file ${favorite.id}:`, response.statusText); + } + } catch (error) { + console.error(`Error loading file details for ${favorite.id}:`, error); + + // Create a fallback file element to prevent the favorite from disappearing + const fallbackFile = { + id: favorite.id, + name: favorite.name || `File ${favorite.id}`, + mime_type: 'application/octet-stream', + size: 0, + modified_at: Math.floor(Date.now() / 1000) + }; + + this.createFavoriteFileElement(fallbackFile, filesGrid, filesListView); + } + }, + + /** + * Create a folder element with favorite indicator + * @param {Object} folder - Folder object + * @param {HTMLElement} filesGrid - Grid view container + * @param {HTMLElement} filesListView - List view container + */ + createFavoriteFolderElement(folder, filesGrid, filesListView) { + // Create standard folder element + const folderGridElement = document.createElement('div'); + folderGridElement.className = 'file-card favorite-item'; + folderGridElement.dataset.folderId = folder.id; + folderGridElement.dataset.folderName = folder.name; + folderGridElement.dataset.parentId = folder.parent_id || ""; + + // Add favorite star + folderGridElement.innerHTML = ` +
+ +
+
+ +
+
${folder.name}
+
Carpeta
+ `; + + // Click to navigate + folderGridElement.addEventListener('click', () => { + window.app.currentPath = folder.id; + window.ui.updateBreadcrumb(folder.name); + window.loadFiles(); + }); + + // Context menu + folderGridElement.addEventListener('contextmenu', (e) => { + e.preventDefault(); + + window.app.contextMenuTargetFolder = { + id: folder.id, + name: folder.name, + parent_id: folder.parent_id || "" + }; + + let folderContextMenu = document.getElementById('folder-context-menu'); + folderContextMenu.style.left = `${e.pageX}px`; + folderContextMenu.style.top = `${e.pageY}px`; + folderContextMenu.style.display = 'block'; + }); + + filesGrid.appendChild(folderGridElement); + + // Format date + const modifiedDate = new Date(folder.modified_at * 1000); + const formattedDate = modifiedDate.toLocaleDateString() + ' ' + + modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + + // List view element + const folderListElement = document.createElement('div'); + folderListElement.className = 'file-item favorite-item'; + folderListElement.dataset.folderId = folder.id; + folderListElement.dataset.folderName = folder.name; + folderListElement.dataset.parentId = folder.parent_id || ""; + + folderListElement.innerHTML = ` +
+ +
+
+
+ +
+ ${folder.name} +
+
${window.i18n ? window.i18n.t('files.file_types.folder') : 'Carpeta'}
+
--
+
${formattedDate}
+ `; + + // Click to navigate + folderListElement.addEventListener('click', () => { + window.app.currentPath = folder.id; + window.ui.updateBreadcrumb(folder.name); + window.loadFiles(); + }); + + // Context menu + folderListElement.addEventListener('contextmenu', (e) => { + e.preventDefault(); + + window.app.contextMenuTargetFolder = { + id: folder.id, + name: folder.name, + parent_id: folder.parent_id || "" + }; + + let folderContextMenu = document.getElementById('folder-context-menu'); + folderContextMenu.style.left = `${e.pageX}px`; + folderContextMenu.style.top = `${e.pageY}px`; + folderContextMenu.style.display = 'block'; + }); + + filesListView.appendChild(folderListElement); + }, + + /** + * Create a file element with favorite indicator + * @param {Object} file - File object + * @param {HTMLElement} filesGrid - Grid view container + * @param {HTMLElement} filesListView - List view container + */ + createFavoriteFileElement(file, filesGrid, filesListView) { + // Determine icon and type + let iconClass = 'fas fa-file'; + let iconSpecialClass = ''; + let typeLabel = 'Documento'; + + if (file.mime_type) { + if (file.mime_type.startsWith('image/')) { + iconClass = 'fas fa-file-image'; + iconSpecialClass = 'image-icon'; + typeLabel = window.i18n ? window.i18n.t('files.file_types.image') : 'Imagen'; + } else if (file.mime_type.startsWith('text/')) { + iconClass = 'fas fa-file-alt'; + iconSpecialClass = 'text-icon'; + typeLabel = window.i18n ? window.i18n.t('files.file_types.text') : 'Texto'; + } else if (file.mime_type.startsWith('video/')) { + iconClass = 'fas fa-file-video'; + iconSpecialClass = 'video-icon'; + typeLabel = window.i18n ? window.i18n.t('files.file_types.video') : 'Video'; + } else if (file.mime_type.startsWith('audio/')) { + iconClass = 'fas fa-file-audio'; + iconSpecialClass = 'audio-icon'; + typeLabel = window.i18n ? window.i18n.t('files.file_types.audio') : 'Audio'; + } else if (file.mime_type === 'application/pdf') { + iconClass = 'fas fa-file-pdf'; + iconSpecialClass = 'pdf-icon'; + typeLabel = window.i18n ? window.i18n.t('files.file_types.pdf') : 'PDF'; + } + } + + // Format size and date + const fileSize = window.formatFileSize(file.size); + const modifiedDate = new Date(file.modified_at * 1000); + const formattedDate = modifiedDate.toLocaleDateString() + ' ' + + modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + + // Grid view element + const fileGridElement = document.createElement('div'); + fileGridElement.className = 'file-card favorite-item'; + fileGridElement.dataset.fileId = file.id; + fileGridElement.dataset.fileName = file.name; + fileGridElement.dataset.folderId = file.folder_id || ""; + + fileGridElement.innerHTML = ` +
+ +
+
+ +
+
${file.name}
+
Modificado ${formattedDate.split(' ')[0]}
+ `; + + // Download on click + fileGridElement.addEventListener('click', () => { + window.location.href = `/api/files/${file.id}`; + }); + + // Context menu + fileGridElement.addEventListener('contextmenu', (e) => { + e.preventDefault(); + + window.app.contextMenuTargetFile = { + id: file.id, + name: file.name, + folder_id: file.folder_id || "" + }; + + let fileContextMenu = document.getElementById('file-context-menu'); + fileContextMenu.style.left = `${e.pageX}px`; + fileContextMenu.style.top = `${e.pageY}px`; + fileContextMenu.style.display = 'block'; + }); + + filesGrid.appendChild(fileGridElement); + + // List view element + const fileListElement = document.createElement('div'); + fileListElement.className = 'file-item favorite-item'; + fileListElement.dataset.fileId = file.id; + fileListElement.dataset.fileName = file.name; + fileListElement.dataset.folderId = file.folder_id || ""; + + fileListElement.innerHTML = ` +
+ +
+
+
+ +
+ ${file.name} +
+
${typeLabel}
+
${fileSize}
+
${formattedDate}
+ `; + + // Download on click + fileListElement.addEventListener('click', () => { + window.location.href = `/api/files/${file.id}`; + }); + + // Context menu + fileListElement.addEventListener('contextmenu', (e) => { + e.preventDefault(); + + window.app.contextMenuTargetFile = { + id: file.id, + name: file.name, + folder_id: file.folder_id || "" + }; + + let fileContextMenu = document.getElementById('file-context-menu'); + fileContextMenu.style.left = `${e.pageX}px`; + fileContextMenu.style.top = `${e.pageY}px`; + fileContextMenu.style.display = 'block'; + }); + + filesListView.appendChild(fileListElement); + } +}; + +// Expose favorites module globally +window.favorites = favorites; \ No newline at end of file diff --git a/static/js/inlineViewer.js b/static/js/inlineViewer.js index 0ab4b9ca..b2e55c6e 100644 --- a/static/js/inlineViewer.js +++ b/static/js/inlineViewer.js @@ -256,7 +256,7 @@ class InlineViewer { message.innerHTML = `
-

Error al cargar el archivo.

+

Error al cargar el archivo.

`

Intenta descargarlo directamente.

`; diff --git a/static/js/ui.js b/static/js/ui.js index bb7723ad..14b455fc 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -18,6 +18,9 @@ const ui = {
Descargar
+
+ Añadir a favoritos +
Renombrar
@@ -43,6 +46,9 @@ const ui = {
Descargar
+
+ Añadir a favoritos +
Compartir