adding recent feature + bug fixed

This commit is contained in:
DioCrafts
2025-04-02 05:08:30 +02:00
parent 21e2eb1ea9
commit a79c335b73
21 changed files with 1112 additions and 15 deletions

View File

@@ -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,

View File

@@ -7,4 +7,5 @@ pub mod trash_dto;
pub mod search_dto;
pub mod share_dto;
pub mod favorites_dto;
pub mod recent_dto;

View File

@@ -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<Utc>,
}

View File

@@ -5,4 +5,5 @@ pub mod storage_ports;
pub mod auth_ports;
pub mod trash_ports;
pub mod share_ports;
pub mod favorites_ports;
pub mod favorites_ports;
pub mod recent_ports;

View File

@@ -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<i32>) -> Result<Vec<RecentItemDto>>;
/// 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<bool>;
/// Limpiar toda la lista de elementos recientes
async fn clear_recent_items(&self, user_id: &str) -> Result<()>;
}

View File

@@ -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;

View File

@@ -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<PgPool>,
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<PgPool>, 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<i32>) -> Result<Vec<RecentItemDto>> {
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<bool> {
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(())
}
}

View File

@@ -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<Arc<dyn SearchUseCase>>,
pub share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
pub favorites_service: Option<Arc<dyn FavoritesUseCase>>,
pub recent_service: Option<Arc<dyn RecentItemsUseCase>>,
}
/// Contenedor para servicios de autenticación
@@ -307,6 +310,7 @@ pub struct AppState {
pub trash_service: Option<Arc<dyn TrashUseCase>>,
pub share_service: Option<Arc<dyn crate::application::ports::share_ports::ShareUseCase>>,
pub favorites_service: Option<Arc<dyn FavoritesUseCase>>,
pub recent_service: Option<Arc<dyn RecentItemsUseCase>>,
}
impl Default for AppState {
@@ -777,6 +781,7 @@ impl Default for AppState {
search_service: Some(Arc::new(DummySearchUseCase) as Arc<dyn crate::application::ports::inbound::SearchUseCase>),
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<dyn RecentItemsUseCase>) -> Self {
self.recent_service = Some(recent_service);
self
}
}

View File

@@ -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<T> = Result<T, (axum::http::StatusCode, String)>;

View File

@@ -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<i32>,
}
/// Obtener elementos recientes del usuario
pub async fn get_recent_items(
State(recent_service): State<Arc<dyn RecentItemsUseCase>>,
Query(params): Query<GetRecentParams>,
) -> 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<Arc<dyn RecentItemsUseCase>>,
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<Arc<dyn RecentItemsUseCase>>,
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<Arc<dyn RecentItemsUseCase>>,
) -> 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()
}
}
}

View File

@@ -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<Arc<dyn SearchUseCase>>,
share_service: Option<Arc<dyn ShareUseCase>>,
favorites_service: Option<Arc<dyn FavoritesUseCase>>,
recent_service: Option<Arc<dyn RecentItemsUseCase>>,
) -> Router<crate::common::di::AppState> {
// 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

View File

@@ -629,6 +629,22 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing::info!("Favorites service is disabled (requires database connection)");
None
};
// Initialize recent items service if database is available
let recent_service: Option<Arc<dyn application::ports::recent_ports::RecentItemsUseCase>> =
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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

78
static/css/recent.css Normal file
View File

@@ -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); }
}

View File

@@ -8,7 +8,9 @@
<!-- Styles -->
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/inlineViewer.css">
<link rel="stylesheet" href="/css/fileViewer.css">
<link rel="stylesheet" href="/css/favorites.css">
<link rel="stylesheet" href="/css/recent.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
@@ -16,12 +18,14 @@
<script src="/js/i18n.js"></script>
<script src="/js/languageSelector.js"></script>
<script src="/js/inlineViewer.js"></script>
<script src="/js/fileViewer.js"></script>
<script src="/js/fileRenderer.js"></script>
<script src="/js/fileSharing.js"></script>
<script src="/js/contextMenus.js"></script>
<script src="/js/fileOperations.js"></script>
<script src="/js/search.js"></script>
<script src="/js/favorites.js"></script>
<script src="/js/recent.js"></script>
<script src="/js/ui.js"></script>
<script src="/js/components/sharedView.js"></script>
<script src="/js/app.js"></script>

View File

@@ -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 = `
<div class="action-buttons">
<button class="btn btn-secondary" id="clear-recent-btn">
<i class="fas fa-broom" style="margin-right: 5px;"></i> <span data-i18n="actions.clear_recent">Limpiar recientes</span>
</button>
</div>
<div class="view-toggle">
<button class="toggle-btn active" id="grid-view-btn" title="Vista de cuadrícula">
<i class="fas fa-th"></i>
</button>
<button class="toggle-btn" id="list-view-btn" title="Vista de lista">
<i class="fas fa-list"></i>
</button>
</div>
`;
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 = `
<div class="empty-state">
<i class="fas fa-exclamation-circle" style="font-size: 48px; color: #f44336; margin-bottom: 16px;"></i>
<p>Error al cargar el módulo de archivos recientes</p>
</div>
`;
}
}
}
// 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

View File

@@ -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(

View File

@@ -246,7 +246,7 @@ class InlineViewer {
this.showErrorMessage(container);
}
},
}
// Helper to show error message
showErrorMessage(container) {
@@ -256,7 +256,7 @@ class InlineViewer {
message.innerHTML = `
<div class="inline-viewer-icon"><i class="fas fa-exclamation-triangle"></i></div>
<div class="inline-viewer-text">
<p>Error al cargar el archivo.</p>`
<p>Error al cargar el archivo.</p>
<p>Intenta descargarlo directamente.</p>
</div>
`;

291
static/js/recent.js Normal file
View File

@@ -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 = `
<div class="list-header">
<div data-i18n="files.name">Nombre</div>
<div data-i18n="files.type">Tipo</div>
<div data-i18n="files.size">Tamaño</div>
<div data-i18n="files.last_accessed">Último acceso</div>
</div>
`;
// 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 = `
<i class="fas fa-clock" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i>
<p>${window.i18n ? window.i18n.t('recent.empty_state') : 'No hay archivos recientes'}</p>
<p>${window.i18n ? window.i18n.t('recent.empty_hint') : 'Los archivos que abras aparecerán aquí'}</p>
`;
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 = `
<div class="recent-indicator">
<i class="fas fa-clock"></i>
</div>
<div class="file-icon ${iconSpecialClass}">
<i class="${iconClass}"></i>
</div>
<div class="file-name">${file.name}</div>
<div class="file-info">Accedido ${formattedDate.split(' ')[0]}</div>
`;
// 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 = `
<div class="recent-indicator">
<i class="fas fa-clock"></i>
</div>
<div class="name-cell">
<div class="file-icon ${iconSpecialClass}">
<i class="${iconClass}"></i>
</div>
<span>${file.name}</span>
</div>
<div class="type-cell">${typeLabel}</div>
<div class="size-cell">${fileSize}</div>
<div class="date-cell">${formattedDate}</div>
`;
// 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;

View File

@@ -43,6 +43,9 @@ const ui = {
fileMenu.className = 'context-menu';
fileMenu.id = 'file-context-menu';
fileMenu.innerHTML = `
<div class="context-menu-item" id="view-file-option">
<i class="fas fa-eye"></i> <span data-i18n="actions.view">Ver</span>
</div>
<div class="context-menu-item" id="download-file-option">
<i class="fas fa-download"></i> <span data-i18n="actions.download">Descargar</span>
</div>
@@ -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)

View File

@@ -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",

View File

@@ -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",