From a79c335b7390804433b4878037cfc0244a68c11d Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Wed, 2 Apr 2025 05:08:30 +0200 Subject: [PATCH] adding recent feature + bug fixed --- db/schema.sql | 35 ++- src/application/dtos/mod.rs | 1 + src/application/dtos/recent_dto.rs | 21 ++ src/application/ports/mod.rs | 3 +- src/application/ports/recent_ports.rs | 19 ++ src/application/services/mod.rs | 1 + src/application/services/recent_service.rs | 232 ++++++++++++++ src/common/di.rs | 14 +- src/interfaces/api/handlers/mod.rs | 1 + src/interfaces/api/handlers/recent_handler.rs | 152 +++++++++ src/interfaces/api/routes.rs | 21 +- src/main.rs | 24 +- static/css/recent.css | 78 +++++ static/index.html | 4 + static/js/app.js | 120 +++++++- static/js/contextMenus.js | 41 +++ static/js/inlineViewer.js | 4 +- static/js/recent.js | 291 ++++++++++++++++++ static/js/ui.js | 57 +++- static/locales/en.json | 4 +- static/locales/es.json | 4 +- 21 files changed, 1112 insertions(+), 15 deletions(-) create mode 100644 src/application/dtos/recent_dto.rs create mode 100644 src/application/ports/recent_ports.rs create mode 100644 src/application/services/recent_service.rs create mode 100644 src/interfaces/api/handlers/recent_handler.rs create mode 100644 static/css/recent.css create mode 100644 static/js/recent.js diff --git a/db/schema.sql b/db/schema.sql index 56a867ee..11b4fc39 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -50,8 +50,18 @@ CREATE TABLE IF NOT EXISTS auth.sessions ( CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON auth.sessions(user_id); CREATE INDEX IF NOT EXISTS idx_sessions_refresh_token ON auth.sessions(refresh_token); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON auth.sessions(expires_at); -CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked, expires_at) -WHERE NOT revoked AND expires_at > NOW(); + +-- Create function for active sessions to use in index +CREATE OR REPLACE FUNCTION auth.is_session_active(expires_at timestamptz) +RETURNS boolean AS $$ +BEGIN + RETURN expires_at > now(); +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Create index for active sessions with IMMUTABLE function +CREATE INDEX IF NOT EXISTS idx_sessions_active ON auth.sessions(user_id, revoked) +WHERE NOT revoked AND auth.is_session_active(expires_at); -- File ownership tracking CREATE TABLE IF NOT EXISTS auth.user_files ( @@ -88,6 +98,27 @@ CREATE INDEX IF NOT EXISTS idx_user_favorites_created ON auth.user_favorites(cre -- 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); +-- Table for recent files +CREATE TABLE IF NOT EXISTS auth.user_recent_files ( + 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' + accessed_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_recent_user_id ON auth.user_recent_files(user_id); +CREATE INDEX IF NOT EXISTS idx_user_recent_item_id ON auth.user_recent_files(item_id); +CREATE INDEX IF NOT EXISTS idx_user_recent_type ON auth.user_recent_files(item_type); +CREATE INDEX IF NOT EXISTS idx_user_recent_accessed ON auth.user_recent_files(accessed_at); + +-- Combined index for quick lookups by user and accessed time (for sorting) +CREATE INDEX IF NOT EXISTS idx_user_recent_user_accessed ON auth.user_recent_files(user_id, accessed_at DESC); + +COMMENT ON TABLE auth.user_recent_files IS 'Stores recently accessed files and folders for cross-device synchronization'; + -- Create admin user (password: Admin123!) INSERT INTO auth.users ( id, diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index f3bf665f..e266347f 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -7,4 +7,5 @@ pub mod trash_dto; pub mod search_dto; pub mod share_dto; pub mod favorites_dto; +pub mod recent_dto; diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs new file mode 100644 index 00000000..9e1774f4 --- /dev/null +++ b/src/application/dtos/recent_dto.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; + +/// DTO para elementos recientes +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecentItemDto { + /// Identificador único para el elemento reciente + pub id: String, + + /// ID del usuario propietario + pub user_id: String, + + /// ID del elemento (archivo o carpeta) + pub item_id: String, + + /// Tipo del elemento ('file' o 'folder') + pub item_type: String, + + /// Cuándo se accedió al elemento + pub accessed_at: DateTime, +} \ No newline at end of file diff --git a/src/application/ports/mod.rs b/src/application/ports/mod.rs index edf41e8a..5c88d939 100644 --- a/src/application/ports/mod.rs +++ b/src/application/ports/mod.rs @@ -5,4 +5,5 @@ pub mod storage_ports; pub mod auth_ports; pub mod trash_ports; pub mod share_ports; -pub mod favorites_ports; \ No newline at end of file +pub mod favorites_ports; +pub mod recent_ports; \ No newline at end of file diff --git a/src/application/ports/recent_ports.rs b/src/application/ports/recent_ports.rs new file mode 100644 index 00000000..47d79e10 --- /dev/null +++ b/src/application/ports/recent_ports.rs @@ -0,0 +1,19 @@ +use async_trait::async_trait; +use crate::common::errors::Result; +use crate::application::dtos::recent_dto::RecentItemDto; + +/// Define operaciones para gestionar elementos recientes del usuario +#[async_trait] +pub trait RecentItemsUseCase: Send + Sync { + /// Obtener todos los elementos recientes de un usuario + async fn get_recent_items(&self, user_id: &str, limit: Option) -> Result>; + + /// Registrar acceso a un elemento + async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>; + + /// Eliminar un elemento de recientes + async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result; + + /// Limpiar toda la lista de elementos recientes + async fn clear_recent_items(&self, user_id: &str) -> Result<()>; +} \ No newline at end of file diff --git a/src/application/services/mod.rs b/src/application/services/mod.rs index b09ed936..d2fd54d5 100644 --- a/src/application/services/mod.rs +++ b/src/application/services/mod.rs @@ -14,6 +14,7 @@ pub mod trash_service; pub mod search_service; pub mod share_service; pub mod favorites_service; +pub mod recent_service; #[cfg(test)] mod trash_service_test; diff --git a/src/application/services/recent_service.rs b/src/application/services/recent_service.rs new file mode 100644 index 00000000..ed61f3de --- /dev/null +++ b/src/application/services/recent_service.rs @@ -0,0 +1,232 @@ +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::recent_ports::RecentItemsUseCase; +use crate::application::dtos::recent_dto::RecentItemDto; + +/// Implementación del caso de uso para gestionar elementos recientes +pub struct RecentService { + db_pool: Arc, + max_recent_items: i32, // Número máximo de elementos recientes a mantener por usuario +} + +impl RecentService { + /// Crear un nuevo servicio de elementos recientes + pub fn new(db_pool: Arc, max_recent_items: i32) -> Self { + Self { + db_pool, + max_recent_items: max_recent_items.max(1).min(100), // Entre 1 y 100 + } + } +} + +#[async_trait] +impl RecentItemsUseCase for RecentService { + /// Obtener elementos recientes de un usuario + async fn get_recent_items(&self, user_id: &str, limit: Option) -> Result> { + info!("Obteniendo elementos recientes para usuario: {}", user_id); + + // Convertir user_id a UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Determinar límite (usar el especificado o el máximo del servicio) + let limit_value = limit.unwrap_or(self.max_recent_items).min(self.max_recent_items); + + // Ejecutar consulta SQL + 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", + accessed_at as "accessed_at" + FROM auth.user_recent_files + WHERE user_id = $1 + ORDER BY accessed_at DESC + LIMIT $2 + "# + ) + .bind(user_uuid) + .bind(limit_value) + .fetch_all(&*self.db_pool) + .await + .map_err(|e| { + error!("Error de base de datos al obtener elementos recientes: {}", e); + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Fallo al obtener elementos recientes: {}", e) + ) + })?; + + // Convertir filas a DTOs + let mut recent_items = Vec::with_capacity(rows.len()); + for row in rows { + recent_items.push(RecentItemDto { + id: row.get("id"), + user_id: row.get("user_id"), + item_id: row.get("item_id"), + item_type: row.get("item_type"), + accessed_at: row.get("accessed_at"), + }); + } + + info!("Recuperados {} elementos recientes para usuario {}", recent_items.len(), user_id); + Ok(recent_items) + } + + /// Registrar acceso a un elemento + async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> { + info!("Registrando acceso a {} '{}' para usuario {}", item_type, item_id, user_id); + + // Validar tipo de elemento + if item_type != "file" && item_type != "folder" { + return Err(DomainError::new( + ErrorKind::InvalidInput, + "RecentItems", + "El tipo de elemento debe ser 'file' o 'folder'" + )); + } + + // Convertir user_id a UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Ejecutar consulta SQL con UPSERT para mantener un único registro por elemento + sqlx::query( + r#" + INSERT INTO auth.user_recent_files (user_id, item_id, item_type, accessed_at) + VALUES ($1, $2, $3, CURRENT_TIMESTAMP) + ON CONFLICT (user_id, item_id, item_type) + DO UPDATE SET accessed_at = CURRENT_TIMESTAMP + "# + ) + .bind(user_uuid) + .bind(item_id) + .bind(item_type) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Error de base de datos al registrar acceso a elemento: {}", e); + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Fallo al registrar acceso a elemento: {}", e) + ) + })?; + + // Eliminar elementos antiguos que excedan el límite + self.prune_old_items(user_id).await?; + + info!("Registrado correctamente acceso a {} '{}' para usuario {}", item_type, item_id, user_id); + Ok(()) + } + + /// Eliminar un elemento de recientes + async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result { + info!("Eliminando {} '{}' de recientes para usuario {}", item_type, item_id, user_id); + + // Convertir user_id a UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Ejecutar consulta SQL + let result = sqlx::query( + r#" + DELETE FROM auth.user_recent_files + 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!("Error de base de datos al eliminar elemento de recientes: {}", e); + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Fallo al eliminar de recientes: {}", e) + ) + })?; + + let removed = result.rows_affected() > 0; + info!( + "{} {} '{}' de recientes para usuario {}", + if removed { "Eliminado correctamente" } else { "No se encontró" }, + item_type, + item_id, + user_id + ); + + Ok(removed) + } + + /// Limpiar todos los elementos recientes + async fn clear_recent_items(&self, user_id: &str) -> Result<()> { + info!("Limpiando todos los elementos recientes para usuario {}", user_id); + + // Convertir user_id a UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Ejecutar consulta SQL + sqlx::query( + r#" + DELETE FROM auth.user_recent_files + WHERE user_id = $1 + "# + ) + .bind(user_uuid) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Error de base de datos al limpiar elementos recientes: {}", e); + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Fallo al limpiar elementos recientes: {}", e) + ) + })?; + + info!("Limpiados todos los elementos recientes para usuario {}", user_id); + Ok(()) + } +} + +impl RecentService { + /// Método auxiliar para eliminar elementos antiguos que excedan el límite + async fn prune_old_items(&self, user_id: &str) -> Result<()> { + // Convertir user_id a UUID + let user_uuid = Uuid::parse_str(user_id)?; + + // Eliminar elementos antiguos que excedan el límite + sqlx::query( + r#" + DELETE FROM auth.user_recent_files + WHERE id IN ( + SELECT id FROM auth.user_recent_files + WHERE user_id = $1 + ORDER BY accessed_at DESC + OFFSET $2 + ) + "# + ) + .bind(user_uuid) + .bind(self.max_recent_items) + .execute(&*self.db_pool) + .await + .map_err(|e| { + error!("Error al podar elementos recientes antiguos: {}", e); + DomainError::new( + ErrorKind::InternalError, + "RecentItems", + format!("Fallo al limpiar elementos recientes antiguos: {}", e) + ) + })?; + + Ok(()) + } +} \ No newline at end of file diff --git a/src/common/di.rs b/src/common/di.rs index 81a2503f..6bb52541 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -22,6 +22,7 @@ use crate::application::services::storage_mediator::{StorageMediator, FileSystem 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::recent_ports::RecentItemsUseCase; 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}; @@ -241,7 +242,8 @@ impl AppServiceFactory { trash_service, search_service, share_service: None, // No share service by default - favorites_service: None // No favorites service by default + favorites_service: None, // No favorites service by default + recent_service: None // No recent service by default } } } @@ -286,6 +288,7 @@ pub struct ApplicationServices { pub search_service: Option>, pub share_service: Option>, pub favorites_service: Option>, + pub recent_service: Option>, } /// Contenedor para servicios de autenticación @@ -307,6 +310,7 @@ pub struct AppState { pub trash_service: Option>, pub share_service: Option>, pub favorites_service: Option>, + pub recent_service: Option>, } impl Default for AppState { @@ -777,6 +781,7 @@ impl Default for AppState { 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 + recent_service: None, // No recent service in minimal mode }; // Return a minimal app state @@ -789,6 +794,7 @@ impl Default for AppState { trash_service: None, share_service: None, favorites_service: None, + recent_service: None, } } } @@ -808,6 +814,7 @@ impl AppState { trash_service: None, share_service: None, favorites_service: None, + recent_service: None, } } @@ -835,4 +842,9 @@ impl AppState { self.favorites_service = Some(favorites_service); self } + + pub fn with_recent_service(mut self, recent_service: Arc) -> Self { + self.recent_service = Some(recent_service); + self + } } \ No newline at end of file diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index c19e27e9..5696792e 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -7,6 +7,7 @@ pub mod trash_handler; pub mod search_handler; pub mod share_handler; pub mod favorites_handler; +pub mod recent_handler; /// Tipo de resultado para controladores de API pub type ApiResult = Result; diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs new file mode 100644 index 00000000..d7ab3a31 --- /dev/null +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -0,0 +1,152 @@ +use std::sync::Arc; +use axum::{ + extract::{Path, State, Query}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde::{Deserialize, Serialize}; +use tracing::{error, info}; + +use crate::application::ports::recent_ports::RecentItemsUseCase; + +/// Parámetros de consulta para obtener elementos recientes +#[derive(Deserialize)] +pub struct GetRecentParams { + #[serde(default)] + limit: Option, +} + +/// Obtener elementos recientes del usuario +pub async fn get_recent_items( + State(recent_service): State>, + Query(params): Query, +) -> impl IntoResponse { + // Para pruebas, usando ID de usuario fijo + let user_id = "00000000-0000-0000-0000-000000000000"; + + match recent_service.get_recent_items(user_id, params.limit).await { + Ok(items) => { + info!("Recuperados {} elementos recientes para usuario", items.len()); + (StatusCode::OK, Json(items)).into_response() + }, + Err(err) => { + error!("Error al recuperar elementos recientes: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Fallo al recuperar elementos recientes: {}", err) + })) + ).into_response() + } + } +} + +/// Registrar acceso a un elemento +pub async fn record_item_access( + State(recent_service): State>, + Path((item_type, item_id)): Path<(String, String)>, +) -> impl IntoResponse { + // Para pruebas, usando ID de usuario fijo + let user_id = "00000000-0000-0000-0000-000000000000"; + + // Validar tipo de elemento + if item_type != "file" && item_type != "folder" { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "El tipo de elemento debe ser 'file' o 'folder'" + })) + ).into_response(); + } + + match recent_service.record_item_access(user_id, &item_id, &item_type).await { + Ok(_) => { + info!("Registrado acceso a {} '{}' en recientes", item_type, item_id); + ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Acceso registrado correctamente" + })) + ).into_response() + }, + Err(err) => { + error!("Error al registrar acceso en recientes: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Fallo al registrar acceso: {}", err) + })) + ).into_response() + } + } +} + +/// Eliminar un elemento de recientes +pub async fn remove_from_recent( + State(recent_service): State>, + Path((item_type, item_id)): Path<(String, String)>, +) -> impl IntoResponse { + // Para pruebas, usando ID de usuario fijo + let user_id = "00000000-0000-0000-0000-000000000000"; + + match recent_service.remove_from_recent(user_id, &item_id, &item_type).await { + Ok(removed) => { + if removed { + info!("Eliminado {} '{}' de recientes", item_type, item_id); + ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Elemento eliminado de recientes" + })) + ).into_response() + } else { + info!("Elemento {} '{}' no estaba en recientes", item_type, item_id); + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "message": "Elemento no estaba en recientes" + })) + ).into_response() + } + }, + Err(err) => { + error!("Error al eliminar de recientes: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Fallo al eliminar de recientes: {}", err) + })) + ).into_response() + } + } +} + +/// Limpiar todos los elementos recientes +pub async fn clear_recent_items( + State(recent_service): State>, +) -> impl IntoResponse { + // Para pruebas, usando ID de usuario fijo + let user_id = "00000000-0000-0000-0000-000000000000"; + + match recent_service.clear_recent_items(user_id).await { + Ok(_) => { + info!("Limpiados todos los elementos recientes para usuario"); + ( + StatusCode::OK, + Json(serde_json::json!({ + "message": "Elementos recientes limpiados correctamente" + })) + ).into_response() + }, + Err(err) => { + error!("Error al limpiar elementos recientes: {}", err); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Fallo al limpiar elementos recientes: {}", err) + })) + ).into_response() + } + } +} \ No newline at end of file diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 91bfa273..a9071289 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -26,6 +26,7 @@ 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::application::ports::recent_ports::RecentItemsUseCase; use crate::interfaces::api::handlers::folder_handler::FolderHandler; use crate::interfaces::api::handlers::file_handler::FileHandler; @@ -45,6 +46,7 @@ pub fn create_api_routes( search_service: Option>, share_service: Option>, favorites_service: Option>, + recent_service: Option>, ) -> Router { // Create a simplified AppState for the trash view // Setup required components for repository construction @@ -109,12 +111,14 @@ pub fn create_api_routes( 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 + recent_service: recent_service.clone(), // Include the recent 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 - favorites_service: favorites_service.clone() // Include the favorites service for routes + favorites_service: favorites_service.clone(), // Include the favorites service for routes + recent_service: recent_service.clone() // Include the recent service for routes }; // Inicializar el servicio de operaciones por lotes let batch_service = Arc::new(BatchOperationService::default( @@ -340,6 +344,20 @@ pub fn create_api_routes( } else { Router::new() }; + + // Create routes for recent items if the service is available + let recent_router = if let Some(recent_service) = recent_service.clone() { + use crate::interfaces::api::handlers::recent_handler; + + Router::new() + .route("/", get(recent_handler::get_recent_items)) + .route("/{item_type}/{item_id}", post(recent_handler::record_item_access)) + .route("/{item_type}/{item_id}", delete(recent_handler::remove_from_recent)) + .route("/clear", delete(recent_handler::clear_recent_items)) + .with_state(recent_service.clone()) + } else { + Router::new() + }; let mut router = Router::new() .nest("/folders", folders_router) @@ -349,6 +367,7 @@ pub fn create_api_routes( .nest("/shares", share_router) .nest("/s", public_share_router) .nest("/favorites", favorites_router) + .nest("/recent", recent_router) ; // Store the share service in app_state for future use diff --git a/src/main.rs b/src/main.rs index ccd92174..1ba456e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -629,6 +629,22 @@ async fn main() -> Result<(), Box> { tracing::info!("Favorites service is disabled (requires database connection)"); None }; + + // Initialize recent items service if database is available + let recent_service: Option> = + if let Some(ref pool) = db_pool { + // Create a new service with the database pool + let service = Arc::new(application::services::recent_service::RecentService::new( + pool.clone(), + 50 // Maximum recent items per user + )); + + tracing::info!("Recent items service initialized successfully"); + Some(service) + } else { + tracing::info!("Recent items service is disabled (requires database connection)"); + None + }; let application_services = common::di::ApplicationServices { folder_service: folder_service.clone(), @@ -642,6 +658,7 @@ async fn main() -> Result<(), Box> { search_service: search_service.clone(), share_service: share_service.clone(), favorites_service: favorites_service.clone(), + recent_service: recent_service.clone() }; // Create the AppState without Arc first @@ -667,11 +684,16 @@ async fn main() -> Result<(), Box> { app_state = app_state.with_favorites_service(service); } + // Add recent service if available + if let Some(service) = recent_service.clone() { + app_state = app_state.with_recent_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, favorites_service); + let api_routes = create_api_routes(folder_service, file_service, Some(i18n_service), trash_service, search_service, share_service, favorites_service, recent_service); let web_routes = create_web_routes(); // Build the app router diff --git a/static/css/recent.css b/static/css/recent.css new file mode 100644 index 00000000..eeaf01f6 --- /dev/null +++ b/static/css/recent.css @@ -0,0 +1,78 @@ +/* Estilos para la funcionalidad de archivos recientes */ + +/* Indicador de reciente */ +.recent-indicator { + position: absolute; + top: 10px; + right: 10px; + width: 25px; + height: 25px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + color: #6c757d; + z-index: 5; +} + +/* Estilos para los elementos de la vista de recientes */ +.recent-item { + position: relative; +} + +/* Ajustes para la vista de cuadrícula */ +.file-card.recent-item { + border-left: 3px solid #6c757d; +} + +/* Ajustes para la vista de lista */ +.file-item.recent-item { + position: relative; + grid-template-columns: 30px minmax(200px, 2fr) 1fr 1fr 120px; +} + +.file-item.recent-item .recent-indicator { + position: relative; + top: 0; + right: 0; + width: 30px; + height: 30px; +} + +/* Estilos para el estado vacío específico de recientes */ +.recents-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 50px 20px; + text-align: center; + color: #6c757d; +} + +.recents-empty-state i { + font-size: 48px; + color: #6c757d; + margin-bottom: 20px; + opacity: 0.6; +} + +.recents-empty-state p { + margin-bottom: 10px; + max-width: 400px; +} + +/* Tooltip para tiempo de acceso */ +.recent-item .file-info { + cursor: help; +} + +/* Animación para archivos recientes */ +.recent-item { + animation: recent-fade-in 0.3s ease; +} + +@keyframes recent-fade-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} \ No newline at end of file diff --git a/static/index.html b/static/index.html index 6552212e..a91324a5 100644 --- a/static/index.html +++ b/static/index.html @@ -8,7 +8,9 @@ + + @@ -16,12 +18,14 @@ + + diff --git a/static/js/app.js b/static/js/app.js index 29f5b31d..d7377be9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -15,7 +15,8 @@ const app = { isTrashView: false, // Whether we're in trash view isSharedView: false, // Whether we're in shared view isFavoritesView: false, // Whether we're in favorites view - currentSection: 'files', // Current section: 'files', 'trash', 'shared' or 'favorites' + isRecentView: false, // Whether we're in recent files view + currentSection: 'files', // Current section: 'files', 'trash', 'shared', 'favorites' or 'recent' isSearchMode: false, // Whether we're in search mode // File sharing related properties shareDialogItem: null, // Item being shared in share dialog @@ -82,6 +83,14 @@ function initApp() { console.warn('Favorites module not available or not initializable'); } + // Initialize recent files module if available + if (window.recent && window.recent.init) { + console.log('Initializing recent files module'); + window.recent.init(); + } else { + console.warn('Recent files module not available or not initializable'); + } + // Wait for translations to load before checking authentication if (window.i18n && window.i18n.isLoaded && window.i18n.isLoaded()) { // Translations already loaded, proceed with authentication @@ -206,6 +215,13 @@ function setupEventListeners() { return; } + // Check if this is the recent files item + if (item.querySelector('span').getAttribute('data-i18n') === 'nav.recent') { + // Switch to recent files view + switchToRecentFilesView(); + return; + } + // Check if this is the trash item if (item === elements.trashBtn) { // Hide shared view if active @@ -760,6 +776,7 @@ function switchToFilesView() { app.isTrashView = false; app.isSharedView = false; app.isFavoritesView = false; + app.isRecentView = false; app.currentSection = 'files'; // Update UI @@ -929,10 +946,111 @@ function switchToFavoritesView() { } } +/** + * Switch to the recent files view + */ +function switchToRecentFilesView() { + // Hide other views + app.isTrashView = false; + app.isSharedView = false; + app.isFavoritesView = false; + + // Set recent view as active + app.isRecentView = true; + app.currentSection = 'recent'; + + // Remove active class from all nav items + elements.navItems.forEach(navItem => navItem.classList.remove('active')); + + // Find recent nav item and make it active + const recentNavItem = document.querySelector('.nav-item:nth-child(3)'); + if (recentNavItem) { + recentNavItem.classList.add('active'); + } + + // Update UI + 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'); + + // Hide shared view if it exists + if (window.sharedView) { + window.sharedView.hide(); + } + + // Configure actions bar for recent view + elements.actionsBar.innerHTML = ` +
+ +
+
+ + +
+ `; + elements.actionsBar.style.display = 'flex'; + + // Add event listener for clear button + document.getElementById('clear-recent-btn').addEventListener('click', () => { + if (window.recent) { + window.recent.clearRecentFiles(); + window.recent.displayRecentFiles(); + window.ui.showNotification('Limpieza completada', 'Se ha limpiado el historial de archivos recientes'); + } + }); + + // 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 recent files module is initialized + if (window.recent) { + // Display recent files + window.recent.displayRecentFiles(); + } else { + console.error('Recent files 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 archivos recientes

+
+ `; + } + } +} + // Expose view switching functions globally window.switchToFilesView = switchToFilesView; window.switchToSharedView = switchToSharedView; window.switchToFavoritesView = switchToFavoritesView; +window.switchToRecentFilesView = switchToRecentFilesView; /** * Check if user is authenticated and load user's home folder diff --git a/static/js/contextMenus.js b/static/js/contextMenus.js index f8f66b6f..6e4fc479 100644 --- a/static/js/contextMenus.js +++ b/static/js/contextMenus.js @@ -79,6 +79,47 @@ const contextMenus = { }); // File context menu options + 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`) + .then(response => response.json()) + .then(fileDetails => { + // Check if viewable file type + if ((fileDetails.mime_type && fileDetails.mime_type.startsWith('image/')) || + (fileDetails.mime_type && fileDetails.mime_type === 'application/pdf')) { + // Open with inline viewer + if (window.inlineViewer) { + window.inlineViewer.openFile(fileDetails); + } else if (window.fileViewer) { + window.fileViewer.open(fileDetails); + } else { + // If no viewer is available, download directly + window.fileOps.downloadFile( + window.app.contextMenuTargetFile.id, + window.app.contextMenuTargetFile.name + ); + } + } else { + // For non-viewable files, download + window.fileOps.downloadFile( + window.app.contextMenuTargetFile.id, + window.app.contextMenuTargetFile.name + ); + } + }) + .catch(error => { + console.error('Error fetching file details:', error); + // On error, fallback to download + window.fileOps.downloadFile( + window.app.contextMenuTargetFile.id, + window.app.contextMenuTargetFile.name + ); + }); + } + window.ui.closeFileContextMenu(); + }); + document.getElementById('download-file-option').addEventListener('click', () => { if (window.app.contextMenuTargetFile) { window.fileOps.downloadFile( diff --git a/static/js/inlineViewer.js b/static/js/inlineViewer.js index b2e55c6e..f44929e3 100644 --- a/static/js/inlineViewer.js +++ b/static/js/inlineViewer.js @@ -246,7 +246,7 @@ class InlineViewer { this.showErrorMessage(container); } - }, + } // Helper to show error message showErrorMessage(container) { @@ -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/recent.js b/static/js/recent.js new file mode 100644 index 00000000..2f1a81df --- /dev/null +++ b/static/js/recent.js @@ -0,0 +1,291 @@ +/** + * OxiCloud - Recent Files Module + * This file handles tracking and displaying recently accessed files + */ + +// Recent Files Module +const recent = { + // Key for storing recent files in localStorage + STORAGE_KEY: 'oxicloud_recent_files', + + // Maximum number of recent files to store + MAX_RECENT_FILES: 20, + + /** + * Initialize recent files module + */ + init() { + console.log('Initializing recent files module'); + this.ensureRecentFilesStorage(); + this.setupEventListeners(); + }, + + /** + * Make sure the recent files storage is initialized + */ + ensureRecentFilesStorage() { + if (!localStorage.getItem(this.STORAGE_KEY)) { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify([])); + } + }, + + /** + * Set up event listeners to track file access + */ + setupEventListeners() { + // Listen for custom event when a file is accessed + document.addEventListener('file-accessed', (event) => { + if (event.detail && event.detail.file) { + this.addRecentFile(event.detail.file); + } + }); + }, + + /** + * Add a file to recent files + * @param {Object} file - File object containing id, name, folder_id, etc. + */ + addRecentFile(file) { + // Don't add if no file or no ID + if (!file || !file.id) { + return; + } + + // Get current recent files + const recentFiles = this.getRecentFiles(); + + // Remove if file already exists in recent files + const existingIndex = recentFiles.findIndex(item => item.id === file.id); + if (existingIndex !== -1) { + recentFiles.splice(existingIndex, 1); + } + + // Add file with timestamp to the beginning of the array + const fileWithTimestamp = { + ...file, + accessedAt: Date.now() + }; + + recentFiles.unshift(fileWithTimestamp); + + // Keep only the most recent files (limit to MAX_RECENT_FILES) + const trimmedFiles = recentFiles.slice(0, this.MAX_RECENT_FILES); + + // Save back to localStorage + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(trimmedFiles)); + }, + + /** + * Get recent files from localStorage + * @returns {Array} Array of recent file objects with timestamps + */ + getRecentFiles() { + try { + const recentFilesJson = localStorage.getItem(this.STORAGE_KEY); + return recentFilesJson ? JSON.parse(recentFilesJson) : []; + } catch (error) { + console.error('Error loading recent files:', error); + return []; + } + }, + + /** + * Clear all recent files + */ + clearRecentFiles() { + localStorage.setItem(this.STORAGE_KEY, JSON.stringify([])); + }, + + /** + * Display recent files in the UI + */ + async displayRecentFiles() { + try { + const recentFiles = this.getRecentFiles(); + + // Clear existing content + const filesGrid = document.getElementById('files-grid'); + const filesListView = document.getElementById('files-list-view'); + + filesGrid.innerHTML = ''; + filesListView.innerHTML = ` +
+
Nombre
+
Tipo
+
Tamaño
+
Último acceso
+
+ `; + + // Update breadcrumb for recents + window.ui.updateBreadcrumb(window.i18n ? window.i18n.t('nav.recent') : 'Recientes'); + + // Show empty state if no recent files + if (recentFiles.length === 0) { + const emptyState = document.createElement('div'); + emptyState.className = 'empty-state'; + emptyState.innerHTML = ` + +

${window.i18n ? window.i18n.t('recent.empty_state') : 'No hay archivos recientes'}

+

${window.i18n ? window.i18n.t('recent.empty_hint') : 'Los archivos que abras aparecerán aquí'}

+ `; + filesGrid.appendChild(emptyState); + return; + } + + // Process each recent file + for (const recentFile of recentFiles) { + this.createRecentFileElement(recentFile, filesGrid, filesListView); + } + + // Update file icons + window.ui.updateFileIcons(); + + } catch (error) { + console.error('Error displaying recent files:', error); + window.ui.showNotification('Error', 'Error al cargar archivos recientes'); + } + }, + + /** + * Create a file element for a recent file + * @param {Object} file - Recent file object + * @param {HTMLElement} filesGrid - Grid view container + * @param {HTMLElement} filesListView - List view container + */ + createRecentFileElement(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 ? window.formatFileSize(file.size || 0) : '0 B'; + const accessedDate = new Date(file.accessedAt); + const formattedDate = accessedDate.toLocaleDateString() + ' ' + + accessedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); + + // Grid view element + const fileGridElement = document.createElement('div'); + fileGridElement.className = 'file-card recent-item'; + fileGridElement.dataset.fileId = file.id; + fileGridElement.dataset.fileName = file.name; + fileGridElement.dataset.folderId = file.folder_id || ""; + + fileGridElement.innerHTML = ` +
+ +
+
+ +
+
${file.name}
+
Accedido ${formattedDate.split(' ')[0]}
+ `; + + // Download on click + fileGridElement.addEventListener('click', () => { + window.location.href = `/api/files/${file.id}`; + + // Dispatch custom event to update recent files + document.dispatchEvent(new CustomEvent('file-accessed', { + detail: { file } + })); + }); + + // 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 recent-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}`; + + // Dispatch custom event to update recent files + document.dispatchEvent(new CustomEvent('file-accessed', { + detail: { file } + })); + }); + + // 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 recent module globally +window.recent = recent; \ No newline at end of file diff --git a/static/js/ui.js b/static/js/ui.js index 14b455fc..49f58056 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -43,6 +43,9 @@ const ui = { fileMenu.className = 'context-menu'; fileMenu.id = 'file-context-menu'; fileMenu.innerHTML = ` +
+ Ver +
Descargar
@@ -766,9 +769,32 @@ const ui = { }); }); - // Download on click + // View or download on click fileGridElement.addEventListener('click', () => { - window.location.href = `/api/files/${file.id}`; + // Track this file access for recent files + if (window.recent) { + document.dispatchEvent(new CustomEvent('file-accessed', { + detail: { file } + })); + } + + // 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}`; + } }); // Context menu @@ -823,9 +849,32 @@ const ui = { }); }); - // Download on click + // View or download on click fileListElement.addEventListener('click', () => { - window.location.href = `/api/files/${file.id}`; + // Track this file access for recent files + if (window.recent) { + document.dispatchEvent(new CustomEvent('file-accessed', { + detail: { file } + })); + } + + // 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}`; + } }); // Context menu (list view) diff --git a/static/locales/en.json b/static/locales/en.json index 22b30c31..8b0445f2 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -19,12 +19,14 @@ "move_to": "Move to", "delete": "Delete", "download": "Download", + "view": "View", "cancel": "Cancel", "confirm": "Confirm", "share": "Share", "copy": "Copy", "notify": "Notify", - "send": "Send" + "send": "Send", + "clear_recent": "Clear recent" }, "share": { "dialogTitle": "Share Link", diff --git a/static/locales/es.json b/static/locales/es.json index d3416aef..2d6eff0b 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -138,12 +138,14 @@ "move_to": "Mover a", "delete": "Eliminar", "download": "Descargar", + "view": "Ver", "cancel": "Cancelar", "confirm": "Confirmar", "share": "Compartir", "copy": "Copiar", "notify": "Notificar", - "send": "Enviar" + "send": "Enviar", + "clear_recent": "Limpiar recientes" }, "files": { "name": "Nombre",