From 705cb5b069fcd02807656d85485dc770f16692ad Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Wed, 2 Apr 2025 01:22:05 +0200 Subject: [PATCH] adding pdf and image viewer --- Cargo.lock | 232 ++++++++++++ Cargo.toml | 1 + src/infrastructure/repositories/mod.rs | 1 - src/infrastructure/services/mod.rs | 3 +- src/infrastructure/services/zip_service.rs | 228 ++++++++++++ src/interfaces/api/handlers/file_handler.rs | 32 +- src/interfaces/api/handlers/folder_handler.rs | 79 ++++- src/interfaces/api/routes.rs | 7 +- static/css/fileViewer.css | 170 +++++++++ static/css/inlineViewer.css | 207 +++++++++++ static/index.html | 2 + static/js/app.js | 9 + static/js/contextMenus.js | 20 ++ static/js/fileOperations.js | 41 +++ static/js/fileRenderer.js | 38 +- static/js/fileViewer.js | 311 +++++++++++++++++ static/js/inlineViewer.js | 329 ++++++++++++++++++ static/js/ui.js | 6 + static/locales/en.json | 8 + static/locales/es.json | 8 + 20 files changed, 1716 insertions(+), 16 deletions(-) create mode 100644 src/infrastructure/services/zip_service.rs create mode 100644 static/css/fileViewer.css create mode 100644 static/css/inlineViewer.css create mode 100644 static/js/fileViewer.js create mode 100644 static/js/inlineViewer.js diff --git a/Cargo.lock b/Cargo.lock index 8d97e068..3f4d51d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.11" @@ -72,6 +83,15 @@ version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" +[[package]] +name = "arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "argon2" version = "0.5.3" @@ -315,12 +335,33 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "cc" version = "1.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fcb57c740ae1daf453ae85f16e37396f672b039e00d9d866e07ddb24e328e3a" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -345,12 +386,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "core-foundation" version = "0.9.4" @@ -425,6 +482,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "deflate64" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" + [[package]] name = "der" version = "0.7.9" @@ -445,6 +508,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "digest" version = "0.10.7" @@ -724,9 +798,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -1141,6 +1217,15 @@ dependencies = [ "hashbrown 0.15.2", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1153,6 +1238,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +dependencies = [ + "getrandom 0.3.2", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1232,12 +1327,39 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lockfree-object-pool" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9374ef4228402d4b7e403e5838cb880d9ee663314b0a900d5a6aabf0c213552e" + [[package]] name = "log" version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "matchers" version = "0.1.0" @@ -1557,6 +1679,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "zip", ] [[package]] @@ -1599,6 +1722,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.5" @@ -2168,6 +2301,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "simple_asn1" version = "0.6.3" @@ -3402,6 +3541,15 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yoke" version = "0.7.5" @@ -3492,6 +3640,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.100", +] [[package]] name = "zerovec" @@ -3514,3 +3676,73 @@ dependencies = [ "quote", "syn 2.0.100", ] + +[[package]] +name = "zip" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c03817464f64e23f6f37574b4fdc8cf65925b5bfd2b0f2aedf959791941f88" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "flate2", + "getrandom 0.3.2", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zopfli" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5019f391bac5cf252e93bbcc53d039ffd62c7bfb7c150414d61369afe57e946" +dependencies = [ + "bumpalo", + "crc32fast", + "lockfree-object-pool", + "log", + "once_cell", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index ff62805a..f936a593 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ tempfile = "3.19.1" tower = "0.5.2" tower-http = { version = "0.6.2", features = ["fs", "compression-gzip", "trace", "cors", "add-extension", "request-id"] } flate2 = "1.1.0" +zip = "2.5.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } chrono = { version = "0.4.40", features = ["serde"] } diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index 049f1cbc..db64b897 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -21,4 +21,3 @@ pub use file_path_resolver::FilePathResolver; pub use file_fs_read_repository::FileFsReadRepository; pub use file_fs_write_repository::FileFsWriteRepository; pub use pg::{UserPgRepository, SessionPgRepository}; -pub use share_fs_repository::ShareFsRepository; \ No newline at end of file diff --git a/src/infrastructure/services/mod.rs b/src/infrastructure/services/mod.rs index 889de4d2..962189c4 100644 --- a/src/infrastructure/services/mod.rs +++ b/src/infrastructure/services/mod.rs @@ -5,4 +5,5 @@ pub mod cache_manager; pub mod file_metadata_cache; pub mod compression_service; pub mod buffer_pool; -pub mod trash_cleanup_service; \ No newline at end of file +pub mod trash_cleanup_service; +pub mod zip_service; \ No newline at end of file diff --git a/src/infrastructure/services/zip_service.rs b/src/infrastructure/services/zip_service.rs new file mode 100644 index 00000000..1d43df16 --- /dev/null +++ b/src/infrastructure/services/zip_service.rs @@ -0,0 +1,228 @@ +use std::io::{Cursor, Read, Write}; +use zip::{ZipWriter, write::SimpleFileOptions}; +use thiserror::Error; +use tracing::*; +use crate::{ + application::dtos::file_dto::FileDto, + application::dtos::folder_dto::FolderDto, + application::ports::inbound::{FileUseCase, FolderUseCase}, + common::errors::{Result, DomainError, ErrorKind}, +}; +use std::sync::Arc; + +/// Error relacionado con la creación de archivos ZIP +#[derive(Debug, Error)] +pub enum ZipError { + #[error("Error de IO: {0}")] + IoError(#[from] std::io::Error), + + #[error("Error de ZIP: {0}")] + ZipError(#[from] zip::result::ZipError), + + #[error("Error al leer el archivo: {0}")] + FileReadError(String), + + #[error("Error al obtener contenido de carpeta: {0}")] + FolderContentsError(String), + + #[error("Carpeta no encontrada: {0}")] + FolderNotFound(String), +} + +// Implementar From para DomainError para permitir el uso de ? +impl From for DomainError { + fn from(err: ZipError) -> Self { + DomainError::new(ErrorKind::InternalError, "zip_service", err.to_string()) + } +} + +// Implementar From para DomainError directamente +impl From for DomainError { + fn from(err: zip::result::ZipError) -> Self { + DomainError::new(ErrorKind::InternalError, "zip_service", err.to_string()) + } +} + +/// Servicio para crear archivos ZIP +pub struct ZipService { + file_service: Arc, + folder_service: Arc, +} + +impl ZipService { + /// Crea una nueva instancia del servicio ZIP con una referencia al servicio de archivos + pub fn new(file_service: Arc, folder_service: Arc) -> Self { + Self { + file_service, + folder_service, + } + } + + /// Crea un archivo ZIP con el contenido de una carpeta y todas sus subcarpetas + /// Retorna los bytes del ZIP + pub async fn create_folder_zip(&self, folder_id: &str, folder_name: &str) -> Result> { + info!("Creando ZIP para carpeta: {} (ID: {})", folder_name, folder_id); + + // Verificar si la carpeta existe + let folder = match self.folder_service.get_folder(folder_id).await { + Ok(folder) => folder, + Err(e) => { + error!("Error al obtener carpeta {}: {}", folder_id, e); + return Err(ZipError::FolderNotFound(folder_id.to_string()).into()); + } + }; + + // Crear un buffer en memoria para el ZIP + let buf = Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(buf); + + // Establecer opciones de compresión + let options = SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o755); + + // Objeto para seguir las carpetas procesadas y evitar ciclos + let mut processed_folders = std::collections::HashSet::new(); + + // Procesamos la carpeta raíz y construimos el ZIP + self.process_folder_recursively( + &mut zip, + &folder, + folder_name, + &options, + &mut processed_folders + ).await?; + + // Finalizar el ZIP y obtener los bytes + let mut zip_buf = zip.finish()?; + + let mut bytes = Vec::new(); + match zip_buf.read_to_end(&mut bytes) { + Ok(_) => Ok(bytes), + Err(e) => { + error!("Error al leer ZIP finalizado: {}", e); + Err(ZipError::IoError(e).into()) + } + } + } + + // Implementación alternativa para evitar recursión en async + async fn process_folder_recursively( + &self, + zip: &mut ZipWriter>>, + folder: &FolderDto, + path: &str, + options: &SimpleFileOptions, + processed_folders: &mut std::collections::HashSet + ) -> Result<()> { + // Estructura para representar el trabajo pendiente + struct PendingFolder { + folder: FolderDto, + path: String, + } + + // Cola de trabajo para procesamiento iterativo + let mut work_queue = vec![PendingFolder { + folder: folder.clone(), + path: path.to_string(), + }]; + + // Procesar la cola mientras haya elementos + while let Some(current) = work_queue.pop() { + let folder_id = current.folder.id.to_string(); + + // Evitar ciclos + if processed_folders.contains(&folder_id) { + continue; + } + + processed_folders.insert(folder_id.clone()); + + // Crear la entrada de directorio en el ZIP + let folder_path = format!("{}/", current.path); + match zip.add_directory(&folder_path, *options) { + Ok(_) => debug!("Carpeta agregada al ZIP: {}", folder_path), + Err(e) => { + warn!("No se pudo agregar carpeta al ZIP (puede que ya exista): {}", e); + // Continuamos aunque falle crear el directorio (podría estar duplicado) + } + } + + // Agregar archivos de la carpeta al ZIP + let files = match self.file_service.list_files(Some(&folder_id)).await { + Ok(files) => files, + Err(e) => { + error!("Error al listar archivos en carpeta {}: {}", folder_id, e); + return Err(ZipError::FolderContentsError(format!("Error al listar archivos: {}", e)).into()); + } + }; + + // Agregar cada archivo al ZIP + for file in files { + self.add_file_to_zip(zip, &file, &folder_path, options).await?; + } + + // Procesar subcarpetas + let subfolders = match self.folder_service.list_folders(Some(&folder_id)).await { + Ok(folders) => folders, + Err(e) => { + error!("Error al listar subcarpetas en {}: {}", folder_id, e); + return Err(ZipError::FolderContentsError(format!("Error al listar subcarpetas: {}", e)).into()); + } + }; + + // Agregar subcarpetas a la cola + for subfolder in subfolders { + let subfolder_path = format!("{}/{}", current.path, subfolder.name); + work_queue.push(PendingFolder { + folder: subfolder, + path: subfolder_path, + }); + } + } + + Ok(()) + } + + // Agrega un archivo al ZIP + async fn add_file_to_zip( + &self, + zip: &mut ZipWriter>>, + file: &FileDto, + folder_path: &str, + options: &SimpleFileOptions, + ) -> Result<()> { + let file_path = format!("{}{}", folder_path, file.name); + info!("Agregando archivo al ZIP: {}", file_path); + + // Obtener el contenido del archivo + let file_id = file.id.to_string(); + let content = match self.file_service.get_file_content(&file_id).await { + Ok(content) => content, + Err(e) => { + error!("Error al leer contenido del archivo {}: {}", file_id, e); + return Err(ZipError::FileReadError(format!("Error al leer archivo {}: {}", file_id, e)).into()); + } + }; + + // Escribir archivo al ZIP + match zip.start_file_from_path(std::path::Path::new(&file_path), *options) { + Ok(_) => { + match zip.write_all(&content) { + Ok(_) => { + debug!("Archivo agregado al ZIP: {}", file_path); + Ok(()) + }, + Err(e) => { + error!("Error al escribir contenido del archivo {}: {}", file_path, e); + Err(ZipError::IoError(e).into()) + } + } + }, + Err(e) => { + error!("Error al iniciar archivo en ZIP {}: {}", file_path, e); + Err(ZipError::ZipError(e).into()) + } + } + } +} \ No newline at end of file diff --git a/src/interfaces/api/handlers/file_handler.rs b/src/interfaces/api/handlers/file_handler.rs index 446da24b..4cfda623 100644 --- a/src/interfaces/api/handlers/file_handler.rs +++ b/src/interfaces/api/handlers/file_handler.rs @@ -196,10 +196,20 @@ impl FileHandler { Ok(content) => { // Create base headers let mut headers = HashMap::new(); - headers.insert( - header::CONTENT_DISPOSITION.to_string(), + + // Determine if the file should be displayed inline or downloaded + // Images and PDFs should be displayed inline by default, or if inline param is present + let force_inline = params.get("inline").map_or(false, |v| v == "true" || v == "1"); + + let disposition = if force_inline || + file.mime_type.starts_with("image/") || + file.mime_type == "application/pdf" { + format!("inline; filename=\"{}\"", file.name) + } else { format!("attachment; filename=\"{}\"", file.name) - ); + }; + + headers.insert(header::CONTENT_DISPOSITION.to_string(), disposition); if should_compress { // Add content-encoding header for compressed response @@ -290,10 +300,20 @@ impl FileHandler { Ok(content) => { // Create base headers let mut headers = HashMap::new(); - headers.insert( - header::CONTENT_DISPOSITION.to_string(), + + // Determine if the file should be displayed inline or downloaded + // Images and PDFs should be displayed inline by default, or if inline param is present + let force_inline = params.get("inline").map_or(false, |v| v == "true" || v == "1"); + + let disposition = if force_inline || + file.mime_type.starts_with("image/") || + file.mime_type == "application/pdf" { + format!("inline; filename=\"{}\"", file.name) + } else { format!("attachment; filename=\"{}\"", file.name) - ); + }; + + headers.insert(header::CONTENT_DISPOSITION.to_string(), disposition); if should_compress { // Add content-encoding header for compressed response diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 656e7308..324d0343 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -1,7 +1,8 @@ use std::sync::Arc; +use std::collections::HashMap; use axum::{ extract::{Path, State, Query}, - http::StatusCode, + http::{StatusCode, header, HeaderName, HeaderValue, Response}, response::IntoResponse, Json, }; @@ -13,6 +14,7 @@ use crate::common::errors::ErrorKind; use crate::application::ports::inbound::FolderUseCase; use crate::common::di::AppState as GlobalAppState; use crate::interfaces::middleware::auth::AuthUser; +use crate::infrastructure::services::zip_service::ZipService; type AppState = Arc; @@ -213,4 +215,79 @@ impl FolderHandler { } } } + + /// Downloads a folder as a ZIP file + pub async fn download_folder_zip( + State(state): State, + Path(id): Path, + Query(_params): Query>, + ) -> impl IntoResponse { + tracing::info!("Downloading folder as ZIP: {}", id); + + // Get folder information first to check it exists and get name + let folder_service = &state.applications.folder_service; + let file_service = &state.applications.file_service; + + match folder_service.get_folder(&id).await { + Ok(folder) => { + tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); + + // Create ZIP service with the required services + let zip_service = ZipService::new( + file_service.clone(), + folder_service.clone() + ); + + // Create the ZIP file + match zip_service.create_folder_zip(&id, &folder.name).await { + Ok(zip_data) => { + tracing::info!("ZIP file created successfully, size: {} bytes", zip_data.len()); + + // Setup headers for download + let filename = format!("{}.zip", folder.name); + let content_disposition = format!("attachment; filename=\"{}\"", filename); + + // Build response with the ZIP data + let mut headers = HashMap::new(); + headers.insert(header::CONTENT_TYPE.to_string(), "application/zip".to_string()); + headers.insert(header::CONTENT_DISPOSITION.to_string(), content_disposition); + headers.insert(header::CONTENT_LENGTH.to_string(), zip_data.len().to_string()); + + // Build the response + let mut response = Response::builder() + .status(StatusCode::OK) + .body(axum::body::Body::from(zip_data)) + .unwrap(); + + // Add headers to response + for (name, value) in headers { + response.headers_mut().insert( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(&value).unwrap() + ); + } + + response + }, + Err(err) => { + tracing::error!("Error creating ZIP file: {}", err); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ + "error": format!("Error creating ZIP file: {}", err) + }))).into_response() + } + } + }, + Err(err) => { + tracing::error!("Folder not found: {}", err); + let status = match err.kind { + ErrorKind::NotFound => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + (status, Json(serde_json::json!({ + "error": format!("Error finding folder: {}", err) + }))).into_response() + } + } + } } \ No newline at end of file diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 09b42882..5d15456e 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -168,6 +168,11 @@ pub fn create_api_routes( .route("/{id}/move", put(FolderHandler::move_folder)) .with_state(folder_service.clone()); + // Special route for ZIP download that requires AppState instead of just FolderService + let folder_zip_router = Router::new() + .route("/{id}/download", get(FolderHandler::download_folder_zip)) + .with_state(app_state.clone()); + // Create folder operations that use trash separately let folders_ops_router = Router::new() .route("/{id}", delete(| @@ -200,7 +205,7 @@ pub fn create_api_routes( })); // Merge the routers - let folders_router = folders_basic_router.merge(folders_ops_router); + let folders_router = folders_basic_router.merge(folders_ops_router).merge(folder_zip_router); // Create file routes for basic operations and trash-enabled delete let basic_file_router = Router::new() diff --git a/static/css/fileViewer.css b/static/css/fileViewer.css new file mode 100644 index 00000000..5a6f5037 --- /dev/null +++ b/static/css/fileViewer.css @@ -0,0 +1,170 @@ +/* OxiCloud File Viewer Styles */ + +.file-viewer-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.85); + z-index: 3000; + display: none !important; + opacity: 0; + transition: opacity 0.3s ease; + justify-content: center; + align-items: center; +} + +.file-viewer-container.active { + display: flex !important; + opacity: 1; +} + +.file-viewer-content { + width: 90%; + height: 90%; + background-color: #fff; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + max-width: 1200px; +} + +.file-viewer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + background-color: #f8f9fa; + border-bottom: 1px solid #e2e8f0; +} + +.file-viewer-title { + font-size: 18px; + font-weight: 500; + color: #2d3748; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-viewer-close { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: #718096; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; +} + +.file-viewer-close:hover { + background-color: #e2e8f0; + color: #4a5568; +} + +.file-viewer-area { + flex-grow: 1; + overflow: auto; + background-color: #f8f9fa; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.file-viewer-toolbar { + display: flex; + gap: 10px; + padding: 10px 20px; + background-color: #f8f9fa; + border-top: 1px solid #e2e8f0; + justify-content: flex-end; +} + +.file-viewer-toolbar button { + background: none; + border: 1px solid #e2e8f0; + border-radius: 4px; + padding: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: #4a5568; + transition: all 0.2s; +} + +.file-viewer-toolbar button:hover { + background-color: #e2e8f0; + color: #2d3748; +} + +/* Image viewer styles */ +.file-viewer-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transform-origin: center; + transition: transform 0.2s ease; +} + +/* PDF viewer styles */ +.file-viewer-pdf { + width: 100%; + height: 100%; + border: none; +} + +/* Loader */ +.file-viewer-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 24px; + color: #4a5568; +} + +/* Unsupported file message */ +.file-viewer-unsupported { + text-align: center; + padding: 40px; + max-width: 500px; +} + +.file-viewer-unsupported i { + font-size: 48px; + color: #a0aec0; + margin-bottom: 20px; +} + +.file-viewer-unsupported p { + margin-bottom: 20px; + color: #4a5568; + font-size: 16px; +} + +.file-viewer-unsupported .download-btn { + margin-top: 20px; +} + +/* Responsive styles */ +@media (max-width: 768px) { + .file-viewer-content { + width: 100%; + height: 100%; + border-radius: 0; + } + + .file-viewer-toolbar { + padding: 10px; + } +} \ No newline at end of file diff --git a/static/css/inlineViewer.css b/static/css/inlineViewer.css new file mode 100644 index 00000000..0f8ee1d0 --- /dev/null +++ b/static/css/inlineViewer.css @@ -0,0 +1,207 @@ +/* OxiCloud Inline Viewer Styles */ + +.inline-viewer-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + background-color: rgba(0, 0, 0, 0.85); + display: none; + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; +} + +.inline-viewer-modal.active { + display: flex !important; + opacity: 1; + align-items: center; + justify-content: center; + pointer-events: all; +} + +.inline-viewer-content { + width: 90%; + height: 90%; + max-width: 1200px; + background-color: #fff; + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.inline-viewer-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background-color: #f8f9fa; + border-bottom: 1px solid #e2e8f0; +} + +.inline-viewer-title { + font-size: 18px; + font-weight: 500; + color: #2d3748; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.inline-viewer-close { + background: none; + border: none; + font-size: 18px; + cursor: pointer; + color: #718096; + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; +} + +.inline-viewer-close:hover { + background-color: #e2e8f0; + color: #4a5568; +} + +.inline-viewer-container { + flex-grow: 1; + overflow: auto; + background-color: #f0f3f7; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.inline-viewer-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background-color: #f8f9fa; + border-top: 1px solid #e2e8f0; +} + +.inline-viewer-download { + background-color: #ff5e3a; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: background-color 0.2s; +} + +.inline-viewer-download:hover { + background-color: #e74c3c; +} + +.inline-viewer-controls { + display: flex; + gap: 8px; +} + +.inline-viewer-controls button { + background-color: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 4px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #64748b; + transition: all 0.2s; +} + +.inline-viewer-controls button:hover { + background-color: #e2e8f0; + color: #334155; +} + +/* Image viewer */ +.inline-viewer-image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transform-origin: center; + transition: transform 0.2s ease; +} + +/* PDF viewer */ +.inline-viewer-pdf, +.inline-viewer-pdf-fallback { + width: 100%; + height: 100%; + border: none; +} + +/* Only show fallback if object fails */ +.inline-viewer-pdf + .inline-viewer-pdf-fallback { + display: none; +} + +.inline-viewer-pdf:not([data]), +.inline-viewer-pdf[data=""] + .inline-viewer-pdf-fallback { + display: block; +} + +/* Loading indicator */ +.inline-viewer-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 36px; + color: #64748b; +} + +/* Error and unsupported message */ +.inline-viewer-message { + padding: 32px; + text-align: center; + max-width: 400px; +} + +.inline-viewer-icon { + font-size: 64px; + color: #cbd5e1; + margin-bottom: 24px; +} + +.inline-viewer-text { + color: #64748b; + line-height: 1.6; +} + +.inline-viewer-text p { + margin: 0 0 16px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .inline-viewer-content { + width: 100%; + height: 100%; + border-radius: 0; + } + + .inline-viewer-controls { + display: none; + } +} \ No newline at end of file diff --git a/static/index.html b/static/index.html index f6145699..38297f87 100644 --- a/static/index.html +++ b/static/index.html @@ -7,12 +7,14 @@ + + diff --git a/static/js/app.js b/static/js/app.js index 130ae205..6058d304 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -56,6 +56,15 @@ function initApp() { console.log('Using standard file rendering'); } + // Check if inline viewer is initialized + if (window.inlineViewer) { + console.log('Inline viewer is available'); + } else { + console.warn('Inline viewer not initialized yet, will initialize it now'); + // Create inline viewer if not already created + window.inlineViewer = new InlineViewer(); + } + // Wait for translations to load before checking authentication if (window.i18n && window.i18n.isLoaded && window.i18n.isLoaded()) { // Translations already loaded, proceed with authentication diff --git a/static/js/contextMenus.js b/static/js/contextMenus.js index 9e796fd2..f957d6a3 100644 --- a/static/js/contextMenus.js +++ b/static/js/contextMenus.js @@ -10,6 +10,16 @@ const contextMenus = { */ assignMenuEvents() { // Folder context menu options + document.getElementById('download-folder-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFolder) { + window.fileOps.downloadFolder( + window.app.contextMenuTargetFolder.id, + window.app.contextMenuTargetFolder.name + ); + } + window.ui.closeContextMenu(); + }); + document.getElementById('rename-folder-option').addEventListener('click', () => { if (window.app.contextMenuTargetFolder) { this.showRenameDialog(window.app.contextMenuTargetFolder); @@ -42,6 +52,16 @@ const contextMenus = { }); // File context menu options + document.getElementById('download-file-option').addEventListener('click', () => { + if (window.app.contextMenuTargetFile) { + window.fileOps.downloadFile( + window.app.contextMenuTargetFile.id, + window.app.contextMenuTargetFile.name + ); + } + window.ui.closeFileContextMenu(); + }); + document.getElementById('move-file-option').addEventListener('click', () => { if (window.app.contextMenuTargetFile) { this.showMoveDialog(window.app.contextMenuTargetFile, 'file'); diff --git a/static/js/fileOperations.js b/static/js/fileOperations.js index 06a47d86..ade9a2e1 100644 --- a/static/js/fileOperations.js +++ b/static/js/fileOperations.js @@ -459,6 +459,47 @@ const fileOps = { window.ui.showNotification('Error', 'Error al vaciar la papelera'); return false; } + }, + + /** + * Descargar un archivo + * @param {string} fileId - ID del archivo + * @param {string} fileName - Nombre del archivo + */ + downloadFile(fileId, fileName) { + // Create a link and trigger download + const link = document.createElement('a'); + link.href = `/api/files/${fileId}`; + link.download = fileName; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, + + /** + * Descargar una carpeta como ZIP + * @param {string} folderId - ID de la carpeta + * @param {string} folderName - Nombre de la carpeta + */ + async downloadFolder(folderId, folderName) { + try { + // Show notification to user + window.ui.showNotification('Preparando descarga', 'Preparando la carpeta para descargar...'); + + // Request the server to create a ZIP of the folder + // Since the API might not support this directly, we will simply download with zip parameter + const link = document.createElement('a'); + link.href = `/api/folders/${folderId}/download?format=zip`; + link.download = `${folderName}.zip`; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } catch (error) { + console.error('Error downloading folder:', error); + window.ui.showNotification('Error', 'Error al descargar la carpeta'); + } } }; diff --git a/static/js/fileRenderer.js b/static/js/fileRenderer.js index d7ff6037..4282f60e 100644 --- a/static/js/fileRenderer.js +++ b/static/js/fileRenderer.js @@ -291,9 +291,22 @@ class FileRenderer { }); }); - // Click event (download) - elem.addEventListener('click', () => { - window.location.href = `/api/files/${item.id}`; + // Click event (preview) + elem.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + + console.log('File clicked:', item); + + // Open file in our new inline viewer + if (window.inlineViewer) { + console.log('Using inline viewer'); + window.inlineViewer.openFile(item); + } else { + console.warn('Inline viewer not available, downloading directly'); + // Fallback to direct download if viewer is not available + window.location.href = `/api/files/${item.id}`; + } }); } @@ -422,9 +435,22 @@ class FileRenderer { }); }); - // Click event (download) - elem.addEventListener('click', () => { - window.location.href = `/api/files/${item.id}`; + // Click event (preview) + elem.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + + console.log('File clicked:', item); + + // Open file in our new inline viewer + if (window.inlineViewer) { + console.log('Using inline viewer'); + window.inlineViewer.openFile(item); + } else { + console.warn('Inline viewer not available, downloading directly'); + // Fallback to direct download if viewer is not available + window.location.href = `/api/files/${item.id}`; + } }); } diff --git a/static/js/fileViewer.js b/static/js/fileViewer.js new file mode 100644 index 00000000..b214c399 --- /dev/null +++ b/static/js/fileViewer.js @@ -0,0 +1,311 @@ +/** + * OxiCloud File Viewer Module + * Provides integrated viewers for images, PDFs and other file types + */ + +class FileViewer { + constructor() { + this.viewerContainer = null; + this.fileData = null; + this.isOpen = false; + + // Create the viewer container on initialization + this.createViewerContainer(); + } + + /** + * Create the viewer container DOM element + */ + createViewerContainer() { + console.log('Creating file viewer container'); + + // Check if container already exists + if (document.getElementById('file-viewer-container')) { + console.log('Viewer container already exists'); + this.viewerContainer = document.getElementById('file-viewer-container'); + return; + } + + // Create viewer container + this.viewerContainer = document.createElement('div'); + this.viewerContainer.id = 'file-viewer-container'; + this.viewerContainer.className = 'file-viewer-container'; + + // Create viewer content + const viewerContent = document.createElement('div'); + viewerContent.className = 'file-viewer-content'; + + // Create header + const header = document.createElement('div'); + header.className = 'file-viewer-header'; + + const title = document.createElement('div'); + title.className = 'file-viewer-title'; + title.textContent = 'File Viewer'; + header.appendChild(title); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'file-viewer-close'; + closeBtn.innerHTML = ''; + closeBtn.addEventListener('click', () => { + console.log('Close button clicked'); + this.close(); + }); + header.appendChild(closeBtn); + + // Create viewer area + const viewerArea = document.createElement('div'); + viewerArea.className = 'file-viewer-area'; + + // Create toolbar + const toolbar = document.createElement('div'); + toolbar.className = 'file-viewer-toolbar'; + + const downloadBtn = document.createElement('button'); + downloadBtn.className = 'file-viewer-download'; + downloadBtn.innerHTML = ''; + downloadBtn.addEventListener('click', () => this.downloadFile()); + toolbar.appendChild(downloadBtn); + + // Assemble the viewer + viewerContent.appendChild(header); + viewerContent.appendChild(viewerArea); + viewerContent.appendChild(toolbar); + this.viewerContainer.appendChild(viewerContent); + + // Add to the document + if (document.body) { + console.log('Adding viewer container to body'); + document.body.appendChild(this.viewerContainer); + } else { + console.warn('Document body not ready, will add viewer later'); + // Try to add it after document is ready + setTimeout(() => { + if (document.body) { + console.log('Adding viewer container to body (delayed)'); + document.body.appendChild(this.viewerContainer); + } else { + console.error('Document body still not available after delay'); + } + }, 500); + } + + // Add event listeners for keyboard navigation + document.addEventListener('keydown', (e) => { + if (this.isOpen && e.key === 'Escape') { + console.log('Escape key pressed, closing viewer'); + this.close(); + } + }); + + console.log('Viewer container created'); + } + + /** + * Open the viewer with the specified file + * @param {Object} fileData - File data with id, name, mime_type + */ + async open(fileData) { + console.log('FileViewer: Opening file', fileData); + this.fileData = fileData; + this.isOpen = true; + + // Reset the viewer area + const viewerArea = this.viewerContainer.querySelector('.file-viewer-area'); + viewerArea.innerHTML = ''; + + // Set the title + const title = this.viewerContainer.querySelector('.file-viewer-title'); + title.textContent = fileData.name; + + // Show the container + this.viewerContainer.classList.add('active'); + + // Determine content type and load appropriate viewer + if (fileData.mime_type && fileData.mime_type.startsWith('image/')) { + console.log('FileViewer: Loading image viewer'); + this.loadImageViewer(fileData.id, viewerArea); + } else if (fileData.mime_type && fileData.mime_type === 'application/pdf') { + console.log('FileViewer: Loading PDF viewer'); + this.loadPdfViewer(fileData.id, viewerArea); + } else { + console.log('FileViewer: Unsupported file type', fileData.mime_type); + // For unsupported files, show download prompt + this.showUnsupportedFileMessage(viewerArea); + } + } + + /** + * Load the image viewer + * @param {string} fileId - ID of the file to view + * @param {HTMLElement} container - Container element to render into + */ + loadImageViewer(fileId, container) { + // Create image element + const img = document.createElement('img'); + img.className = 'file-viewer-image'; + img.src = `/api/files/${fileId}`; + img.alt = this.fileData.name; + + // Create loader + const loader = document.createElement('div'); + loader.className = 'file-viewer-loader'; + loader.innerHTML = ''; + container.appendChild(loader); + + // When image loads, remove loader + img.onload = () => { + container.removeChild(loader); + }; + + // Add image to container + container.appendChild(img); + + // Add zoom controls to toolbar + const toolbar = this.viewerContainer.querySelector('.file-viewer-toolbar'); + + const zoomInBtn = document.createElement('button'); + zoomInBtn.className = 'file-viewer-zoom-in'; + zoomInBtn.innerHTML = ''; + zoomInBtn.addEventListener('click', () => this.zoomImage(1.2)); + toolbar.appendChild(zoomInBtn); + + const zoomOutBtn = document.createElement('button'); + zoomOutBtn.className = 'file-viewer-zoom-out'; + zoomOutBtn.innerHTML = ''; + zoomOutBtn.addEventListener('click', () => this.zoomImage(0.8)); + toolbar.appendChild(zoomOutBtn); + + const resetZoomBtn = document.createElement('button'); + resetZoomBtn.className = 'file-viewer-zoom-reset'; + resetZoomBtn.innerHTML = ''; + resetZoomBtn.addEventListener('click', () => this.resetZoom()); + toolbar.appendChild(resetZoomBtn); + } + + /** + * Zoom the image + * @param {number} factor - Zoom factor + */ + zoomImage(factor) { + const img = this.viewerContainer.querySelector('.file-viewer-image'); + if (!img) return; + + // Get current scale + let scale = img.style.transform ? + parseFloat(img.style.transform.replace('scale(', '').replace(')', '')) : 1; + + // Apply new scale + scale *= factor; + + // Limit scale range + scale = Math.max(0.5, Math.min(5, scale)); + + img.style.transform = `scale(${scale})`; + } + + /** + * Reset image zoom + */ + resetZoom() { + const img = this.viewerContainer.querySelector('.file-viewer-image'); + if (!img) return; + + img.style.transform = 'scale(1)'; + } + + /** + * Load the PDF viewer + * @param {string} fileId - ID of the file to view + * @param {HTMLElement} container - Container element to render into + */ + loadPdfViewer(fileId, container) { + // Create iframe for PDF viewer + const iframe = document.createElement('iframe'); + iframe.className = 'file-viewer-pdf'; + iframe.src = `/api/files/${fileId}`; + iframe.title = this.fileData.name; + + // Create loader + const loader = document.createElement('div'); + loader.className = 'file-viewer-loader'; + loader.innerHTML = ''; + container.appendChild(loader); + + // When iframe loads, remove loader + iframe.onload = () => { + container.removeChild(loader); + }; + + // Add iframe to container + container.appendChild(iframe); + } + + /** + * Show message for unsupported file types + * @param {HTMLElement} container - Container element to render into + */ + showUnsupportedFileMessage(container) { + const message = document.createElement('div'); + message.className = 'file-viewer-unsupported'; + + message.innerHTML = ` + +

${window.i18n ? window.i18n.t('viewer.unsupported_file') : 'Este tipo de archivo no se puede previsualizar.'}

+ + `; + + // Add download button click event + message.querySelector('.download-btn').addEventListener('click', () => { + this.downloadFile(); + }); + + container.appendChild(message); + } + + /** + * Download the current file + */ + downloadFile() { + if (!this.fileData) return; + + // Create a link and simulate click + const link = document.createElement('a'); + link.href = `/api/files/${this.fileData.id}`; + link.download = this.fileData.name; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + /** + * Close the viewer + */ + close() { + this.isOpen = false; + this.fileData = null; + this.viewerContainer.classList.remove('active'); + + // Reset toolbar (remove zoom controls) + const toolbar = this.viewerContainer.querySelector('.file-viewer-toolbar'); + const downloadBtn = toolbar.querySelector('.file-viewer-download'); + toolbar.innerHTML = ''; + toolbar.appendChild(downloadBtn); + } +} + +// Create the file viewer immediately and make it accessible globally +window.fileViewer = new FileViewer(); + +// Ensure the file viewer is available when the DOM is ready +document.addEventListener('DOMContentLoaded', () => { + console.log('FileViewer initialized:', window.fileViewer ? 'Yes' : 'No'); + if (!window.fileViewer) { + console.warn('Re-initializing fileViewer as it was not properly set'); + window.fileViewer = new FileViewer(); + } +}); \ No newline at end of file diff --git a/static/js/inlineViewer.js b/static/js/inlineViewer.js new file mode 100644 index 00000000..0ab4b9ca --- /dev/null +++ b/static/js/inlineViewer.js @@ -0,0 +1,329 @@ +/** + * OxiCloud Inline Viewer + * A simpler approach to viewing files that doesn't rely on complex DOM manipulation + */ + +class InlineViewer { + constructor() { + this.setupViewer(); + this.currentFile = null; + } + + setupViewer() { + // Create the viewer modal if it doesn't exist + if (document.getElementById('inline-viewer-modal')) { + return; + } + + // Create modal container + const modal = document.createElement('div'); + modal.id = 'inline-viewer-modal'; + modal.className = 'inline-viewer-modal'; + modal.innerHTML = ` +
+
+
File Viewer
+ +
+
+
+ +
+ + + +
+
+
+ `; + + // Add to document + document.body.appendChild(modal); + + // Add event listeners + modal.querySelector('.inline-viewer-close').addEventListener('click', () => { + this.closeViewer(); + }); + + modal.querySelector('.inline-viewer-download').addEventListener('click', () => { + if (this.currentFile) { + this.downloadFile(this.currentFile); + } + }); + + // Add zoom controls for images + modal.querySelector('.inline-viewer-zoom-in').addEventListener('click', () => { + this.zoomImage(1.2); + }); + + modal.querySelector('.inline-viewer-zoom-out').addEventListener('click', () => { + this.zoomImage(0.8); + }); + + modal.querySelector('.inline-viewer-zoom-reset').addEventListener('click', () => { + this.resetZoom(); + }); + + // Close on ESC key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.classList.contains('active')) { + this.closeViewer(); + } + }); + + // Click outside to close + modal.addEventListener('click', (e) => { + if (e.target === modal) { + this.closeViewer(); + } + }); + + console.log('Inline viewer initialized'); + } + + openFile(file) { + console.log('Opening file:', file); + this.currentFile = file; + + // Get container + const modal = document.getElementById('inline-viewer-modal'); + const container = modal.querySelector('.inline-viewer-container'); + const title = modal.querySelector('.inline-viewer-title'); + + // Clear container + container.innerHTML = ''; + + // Set title + title.textContent = file.name; + + // Set controls visibility + const controls = modal.querySelector('.inline-viewer-controls'); + + // Show viewer based on file type + if (file.mime_type && file.mime_type.startsWith('image/')) { + // Show zoom controls + controls.style.display = 'flex'; + + // Show loading indicator + const loader = document.createElement('div'); + loader.className = 'inline-viewer-loader'; + loader.innerHTML = ''; + container.appendChild(loader); + + // Create image viewer using a blob URL + this.createBlobUrlViewer(file, 'image', container, loader); + } + else if (file.mime_type && file.mime_type === 'application/pdf') { + // Hide zoom controls for PDFs + controls.style.display = 'none'; + + // Show loading indicator + const loader = document.createElement('div'); + loader.className = 'inline-viewer-loader'; + loader.innerHTML = ''; + container.appendChild(loader); + + // Create PDF viewer using object tag with blob URL + this.createBlobUrlViewer(file, 'pdf', container, loader); + } + else { + // Hide zoom controls for unsupported files + controls.style.display = 'none'; + + // Show unsupported file message + const message = document.createElement('div'); + message.className = 'inline-viewer-message'; + message.innerHTML = ` +
+
+

Este tipo de archivo no puede ser previsualizado.

+

Haz clic en "Descargar" para obtener el archivo.

+
+ `; + container.appendChild(message); + } + + // Show modal + modal.classList.add('active'); + } + + // Creates a viewer using a Blob URL to avoid content-disposition header + async createBlobUrlViewer(file, type, container, loader) { + try { + console.log('Creating blob URL viewer for:', file.name, 'type:', type); + + // Use XMLHttpRequest instead of fetch to get better control over the response + const xhr = new XMLHttpRequest(); + xhr.open('GET', `/api/files/${file.id}?inline=true`, true); + xhr.responseType = 'blob'; + + // Create a promise to handle the XHR + const response = await new Promise((resolve, reject) => { + xhr.onload = function() { + if (this.status >= 200 && this.status < 300) { + resolve(this.response); + } else { + reject(new Error(`Error fetching file: ${this.status} ${this.statusText}`)); + } + }; + + xhr.onerror = function() { + reject(new Error('Network error')); + }; + + xhr.send(); + }); + + // Create blob URL from response + const blob = response; + const blobUrl = URL.createObjectURL(blob); + + console.log('Created blob URL:', blobUrl.substring(0, 30) + '...'); + + // Remove loader + if (loader && loader.parentNode) { + loader.parentNode.removeChild(loader); + } + + if (type === 'image') { + console.log('Creating image viewer'); + // Create image element + const img = document.createElement('img'); + img.className = 'inline-viewer-image'; + img.src = blobUrl; + img.alt = file.name; + container.appendChild(img); + + // Add loading indicator until image loads + img.style.opacity = 0; + img.onload = () => { + console.log('Image loaded successfully'); + img.style.opacity = 1; + }; + + img.onerror = () => { + console.error('Failed to load image'); + container.removeChild(img); + this.showErrorMessage(container); + }; + } + else if (type === 'pdf') { + console.log('Creating PDF viewer'); + + // Create iframe for PDF (more reliable than object tag) + const iframe = document.createElement('iframe'); + iframe.className = 'inline-viewer-pdf'; + iframe.src = blobUrl; + iframe.setAttribute('allowfullscreen', 'true'); + container.appendChild(iframe); + + // Monitor iframe for loading issues + setTimeout(() => { + if (!iframe.contentDocument || + iframe.contentDocument.body.innerHTML === '') { + console.warn('PDF viewer might be having issues, adding fallback'); + + // Add fallback embed + const embed = document.createElement('embed'); + embed.className = 'inline-viewer-pdf-fallback'; + embed.type = 'application/pdf'; + embed.src = blobUrl; + container.appendChild(embed); + } + }, 2000); + } + + // Store blob URL for cleaning up later + this.currentBlobUrl = blobUrl; + } + catch (error) { + console.error('Error creating blob URL viewer:', error); + + // Remove loader + if (loader && loader.parentNode) { + loader.parentNode.removeChild(loader); + } + + this.showErrorMessage(container); + } + }, + + // Helper to show error message + showErrorMessage(container) { + // Show error message + const message = document.createElement('div'); + message.className = 'inline-viewer-message'; + message.innerHTML = ` +
+
+

Error al cargar el archivo.

+

Intenta descargarlo directamente.

+
+ `; + container.appendChild(message); + } + + closeViewer() { + // Get modal + const modal = document.getElementById('inline-viewer-modal'); + + // Hide modal + modal.classList.remove('active'); + + // Clean up blob URL if exists + if (this.currentBlobUrl) { + URL.revokeObjectURL(this.currentBlobUrl); + this.currentBlobUrl = null; + } + + // Clear references + this.currentFile = null; + } + + downloadFile(file) { + // Create a link and click it + const link = document.createElement('a'); + link.href = `/api/files/${file.id}`; + link.download = file.name; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + zoomImage(factor) { + const container = document.querySelector('.inline-viewer-container'); + const img = container.querySelector('.inline-viewer-image'); + + if (!img) return; + + // Get current scale + let scale = img.dataset.scale ? parseFloat(img.dataset.scale) : 1.0; + + // Apply zoom factor + scale *= factor; + + // Limit scale + scale = Math.max(0.1, Math.min(5.0, scale)); + + // Save scale + img.dataset.scale = scale; + + // Apply scale + img.style.transform = `scale(${scale})`; + } + + resetZoom() { + const container = document.querySelector('.inline-viewer-container'); + const img = container.querySelector('.inline-viewer-image'); + + if (!img) return; + + // Reset scale + img.dataset.scale = 1.0; + img.style.transform = 'scale(1.0)'; + } +} + +// Initialize viewer +window.inlineViewer = new InlineViewer(); \ No newline at end of file diff --git a/static/js/ui.js b/static/js/ui.js index 20c3827c..bb7723ad 100644 --- a/static/js/ui.js +++ b/static/js/ui.js @@ -15,6 +15,9 @@ const ui = { folderMenu.className = 'context-menu'; folderMenu.id = 'folder-context-menu'; folderMenu.innerHTML = ` +
+ Descargar +
Renombrar
@@ -37,6 +40,9 @@ const ui = { fileMenu.className = 'context-menu'; fileMenu.id = 'file-context-menu'; fileMenu.innerHTML = ` +
+ Descargar +
Compartir
diff --git a/static/locales/en.json b/static/locales/en.json index 21e1af26..22b30c31 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -18,6 +18,7 @@ "move": "Move to...", "move_to": "Move to", "delete": "Delete", + "download": "Download", "cancel": "Cancel", "confirm": "Confirm", "share": "Share", @@ -243,5 +244,12 @@ "admin_password": "Admin password", "create_admin": "Create administrator", "back_to_login": "Already set up?" + }, + "viewer": { + "unsupported_file": "This file type cannot be previewed.", + "download_file": "Download file", + "zoom_in": "Zoom in", + "zoom_out": "Zoom out", + "zoom_reset": "Reset zoom" } } \ No newline at end of file diff --git a/static/locales/es.json b/static/locales/es.json index 00a8897c..d3416aef 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -137,6 +137,7 @@ "move": "Mover a...", "move_to": "Mover a", "delete": "Eliminar", + "download": "Descargar", "cancel": "Cancelar", "confirm": "Confirmar", "share": "Compartir", @@ -243,5 +244,12 @@ "admin_password": "Contraseña administrador", "create_admin": "Crear administrador", "back_to_login": "¿Ya está configurado?" + }, + "viewer": { + "unsupported_file": "Este tipo de archivo no se puede previsualizar.", + "download_file": "Descargar archivo", + "zoom_in": "Acercar", + "zoom_out": "Alejar", + "zoom_reset": "Restablecer zoom" } } \ No newline at end of file