mirror of
https://github.com/DioCrafts/OxiCloud.git
synced 2025-10-06 00:22:38 +02:00
Merge pull request #37 from DioCrafts/feature/recent
adding recent feature + bug fixed
This commit is contained in:
@@ -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,
|
||||
|
@@ -7,4 +7,5 @@ pub mod trash_dto;
|
||||
pub mod search_dto;
|
||||
pub mod share_dto;
|
||||
pub mod favorites_dto;
|
||||
pub mod recent_dto;
|
||||
|
||||
|
21
src/application/dtos/recent_dto.rs
Normal file
21
src/application/dtos/recent_dto.rs
Normal 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>,
|
||||
}
|
@@ -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;
|
19
src/application/ports/recent_ports.rs
Normal file
19
src/application/ports/recent_ports.rs
Normal 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<()>;
|
||||
}
|
@@ -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;
|
||||
|
232
src/application/services/recent_service.rs
Normal file
232
src/application/services/recent_service.rs
Normal 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(())
|
||||
}
|
||||
}
|
@@ -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
|
||||
}
|
||||
}
|
@@ -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)>;
|
||||
|
152
src/interfaces/api/handlers/recent_handler.rs
Normal file
152
src/interfaces/api/handlers/recent_handler.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
24
src/main.rs
24
src/main.rs
@@ -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
78
static/css/recent.css
Normal 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); }
|
||||
}
|
@@ -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>
|
||||
|
120
static/js/app.js
120
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 = `
|
||||
<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
|
||||
|
@@ -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(
|
||||
|
@@ -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
291
static/js/recent.js
Normal 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;
|
@@ -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)
|
||||
|
@@ -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",
|
||||
|
@@ -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",
|
||||
|
Reference in New Issue
Block a user