diff --git a/backend/dockerfile b/backend/Dockerfile similarity index 67% rename from backend/dockerfile rename to backend/Dockerfile index 84f6e6b..1a1a245 100644 --- a/backend/dockerfile +++ b/backend/Dockerfile @@ -19,4 +19,5 @@ RUN uv sync --frozen EXPOSE 8000 # Comando para desarrollo (con reload) -CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file +# Reinstalar dependencias al inicio para asegurar compatibilidad con volumen montado +CMD ["sh", "-c", "uv sync --frozen && uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"] \ No newline at end of file diff --git a/backend/app/routers/__pycache__/files.cpython-312.pyc b/backend/app/routers/__pycache__/files.cpython-312.pyc index fcafacd..4acffca 100644 Binary files a/backend/app/routers/__pycache__/files.cpython-312.pyc and b/backend/app/routers/__pycache__/files.cpython-312.pyc differ diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index b074299..60735a7 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -582,7 +582,7 @@ async def get_file_info( blob_name=filename, tema=tema or "" ) - + # Convertir a objeto FileInfo file_info = FileInfo( name=file_data["name"], @@ -593,12 +593,63 @@ async def get_file_info( content_type=file_data.get("content_type"), url=file_data.get("url") ) - + logger.info(f"Información obtenida para archivo '{filename}'") return file_info - + except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado") except Exception as e: logger.error(f"Error obteniendo info del archivo '{filename}': {e}") + raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}") + + +@router.get("/{filename}/preview-url") +async def get_file_preview_url( + filename: str, + tema: Optional[str] = Query(None, description="Tema donde está el archivo"), + expiry_hours: int = Query(1, description="Horas de validez de la URL (máximo 24)", ge=1, le=24) +): + """ + Generar una URL temporal (SAS) para vista previa de archivos + + Este endpoint genera una URL con firma temporal (Shared Access Signature) + que permite acceder al archivo directamente desde el navegador sin autenticación. + La URL expira después del tiempo especificado por seguridad. + + Casos de uso: + - Vista previa de PDFs en el navegador + - Mostrar imágenes sin descargarlas + - Compartir acceso temporal a archivos + + Args: + filename: Nombre del archivo + tema: Tema donde está ubicado el archivo (opcional) + expiry_hours: Horas de validez de la URL (1-24 horas, por defecto 1) + + Returns: + JSON con la URL temporal del archivo + """ + try: + # Generar SAS URL usando el servicio de Azure + sas_url = await azure_service.generate_sas_url( + blob_name=filename, + tema=tema or "", + expiry_hours=expiry_hours + ) + + logger.info(f"SAS URL generada para preview de '{filename}'" + (f" del tema '{tema}'" if tema else "")) + + return { + "success": True, + "filename": filename, + "url": sas_url, + "expiry_hours": expiry_hours, + "message": f"URL temporal generada (válida por {expiry_hours} hora{'s' if expiry_hours > 1 else ''})" + } + + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado") + except Exception as e: + logger.error(f"Error generando preview URL para '{filename}': {e}") raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}") \ No newline at end of file diff --git a/backend/app/services/__pycache__/azure_service.cpython-312.pyc b/backend/app/services/__pycache__/azure_service.cpython-312.pyc index dffc02f..0173b95 100644 Binary files a/backend/app/services/__pycache__/azure_service.cpython-312.pyc and b/backend/app/services/__pycache__/azure_service.cpython-312.pyc differ diff --git a/backend/app/services/azure_service.py b/backend/app/services/azure_service.py index 94e512b..a6c3a0f 100644 --- a/backend/app/services/azure_service.py +++ b/backend/app/services/azure_service.py @@ -1,8 +1,8 @@ -from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient +from azure.storage.blob import BlobServiceClient, BlobClient, ContainerClient, generate_blob_sas, BlobSasPermissions from azure.core.exceptions import ResourceNotFoundError, ResourceExistsError from typing import List, Optional, BinaryIO import logging -from datetime import datetime, timezone +from datetime import datetime, timezone, timedelta import os from ..core.config import settings @@ -22,9 +22,40 @@ class AzureBlobService: ) self.container_name = settings.AZURE_CONTAINER_NAME logger.info(f"Cliente de Azure Blob Storage inicializado para container: {self.container_name}") + + # Configurar CORS automáticamente al inicializar + self._configure_cors() except Exception as e: logger.error(f"Error inicializando Azure Blob Service: {e}") raise e + + def _configure_cors(self): + """ + Configurar CORS en Azure Blob Storage para permitir acceso desde el frontend + + Esto es necesario para que el navegador pueda cargar PDFs directamente + desde Azure usando las URLs SAS generadas. + """ + try: + from azure.storage.blob import CorsRule + + # Definir regla CORS permisiva para desarrollo y producción + cors_rule = CorsRule( + allowed_origins=["*"], # En producción, especificar dominios exactos + allowed_methods=["GET", "HEAD", "OPTIONS"], + allowed_headers=["*"], + exposed_headers=["*"], + max_age_in_seconds=3600 + ) + + # Aplicar la configuración CORS + self.blob_service_client.set_service_properties(cors=[cors_rule]) + logger.info("CORS configurado exitosamente en Azure Blob Storage") + + except Exception as e: + # No fallar si CORS no se puede configurar (puede que ya esté configurado) + logger.warning(f"No se pudo configurar CORS automáticamente: {e}") + logger.warning("Asegúrate de configurar CORS manualmente en Azure Portal si es necesario") async def create_container_if_not_exists(self) -> bool: """ @@ -237,30 +268,112 @@ class AzureBlobService: async def get_download_url(self, blob_name: str, tema: str = "") -> str: """ Obtener URL de descarga directa para un archivo - + Args: blob_name: Nombre del archivo tema: Tema/carpeta donde está el archivo - + Returns: str: URL de descarga """ try: # Construir la ruta completa full_blob_name = f"{tema}/{blob_name}" if tema else blob_name - + # Obtener cliente del blob blob_client = self.blob_service_client.get_blob_client( container=self.container_name, blob=full_blob_name ) - + return blob_client.url - + except Exception as e: logger.error(f"Error obteniendo URL de descarga para '{blob_name}': {e}") raise e + async def generate_sas_url(self, blob_name: str, tema: str = "", expiry_hours: int = 1) -> str: + """ + Generar una URL SAS (Shared Access Signature) temporal para acceder a un archivo + + Esta URL permite acceso temporal y seguro al archivo sin requerir autenticación. + Es ideal para vistas previas de archivos en el navegador. + + Args: + blob_name: Nombre del archivo + tema: Tema/carpeta donde está el archivo + expiry_hours: Horas de validez de la URL (por defecto 1 hora) + + Returns: + str: URL completa con SAS token para acceso temporal + """ + try: + from azure.storage.blob import ContentSettings + + # Construir la ruta completa del blob + full_blob_name = f"{tema}/{blob_name}" if tema else blob_name + + # Obtener cliente del blob + blob_client = self.blob_service_client.get_blob_client( + container=self.container_name, + blob=full_blob_name + ) + + # Verificar que el archivo existe antes de generar el SAS + if not blob_client.exists(): + raise FileNotFoundError(f"El archivo '{blob_name}' no existe") + + # IMPORTANTE: Configurar el blob para que se muestre inline (no descarga) + # Esto hace que el navegador muestre el PDF en lugar de descargarlo + try: + content_settings = ContentSettings( + content_type='application/pdf', + content_disposition='inline' # Clave para mostrar en navegador + ) + blob_client.set_http_headers(content_settings=content_settings) + logger.info(f"Headers configurados para visualización inline de '{full_blob_name}'") + except Exception as e: + logger.warning(f"No se pudieron configurar headers inline: {e}") + + # Definir el tiempo de expiración del SAS token + start_time = datetime.now(timezone.utc) + expiry_time = start_time + timedelta(hours=expiry_hours) + + # Extraer la account key del connection string para generar el SAS + # El SAS necesita la account key para firmar el token + account_key = None + for part in settings.AZURE_STORAGE_CONNECTION_STRING.split(';'): + if part.startswith('AccountKey='): + account_key = part.split('=', 1)[1] + break + + if not account_key: + raise ValueError("No se pudo extraer AccountKey del connection string") + + # Generar el SAS token con permisos de solo lectura + sas_token = generate_blob_sas( + account_name=blob_client.account_name, + container_name=self.container_name, + blob_name=full_blob_name, + account_key=account_key, + permission=BlobSasPermissions(read=True), # Solo permisos de lectura + expiry=expiry_time, + start=start_time + ) + + # Construir la URL completa con el SAS token + sas_url = f"{blob_client.url}?{sas_token}" + + logger.info(f"SAS URL generada para '{full_blob_name}' (válida por {expiry_hours} horas)") + return sas_url + + except FileNotFoundError: + logger.error(f"Archivo '{full_blob_name}' no encontrado para generar SAS") + raise + except Exception as e: + logger.error(f"Error generando SAS URL para '{blob_name}': {e}") + raise e + # Instancia global del servicio azure_service = AzureBlobService() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a3fed3c..9a9c58d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: frontend: build: ./frontend @@ -24,25 +22,9 @@ services: volumes: - ./backend:/app - /app/.venv - - depends_on: - - qdrant - networks: - - app-network - - qdrant: - image: qdrant/qdrant:latest - ports: - - "6333:6333" - - "6334:6334" - volumes: - - qdrant_storage:/qdrant/storage networks: - app-network networks: app-network: - driver: bridge - -volumes: - qdrant_storage: \ No newline at end of file + driver: bridge \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b801fcc --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Copy application files +COPY . . + +# Install dependencies (will be done at runtime due to volume mount) +RUN npm install + +# Expose Vite dev server port +EXPOSE 5173 + +# Run development server with host binding for Docker +CMD ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4aea2c0..32c2d10 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,9 +15,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.543.0", + "pdfjs-dist": "^5.4.296", "react": "^19.1.1", "react-dom": "^19.1.1", "react-dropzone": "^14.3.8", + "react-pdf": "^10.2.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "zustand": "^5.0.8" @@ -982,6 +984,191 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.81.tgz", + "integrity": "sha512-ReCjd5SYI/UKx/olaQLC4GtN6wUQGjlgHXs1lvUvWGXfBMR3Fxnik3cL+OxKN5ithNdoU0/GlCrdKcQDFh2XKQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.81", + "@napi-rs/canvas-darwin-arm64": "0.1.81", + "@napi-rs/canvas-darwin-x64": "0.1.81", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.81", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.81", + "@napi-rs/canvas-linux-arm64-musl": "0.1.81", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.81", + "@napi-rs/canvas-linux-x64-gnu": "0.1.81", + "@napi-rs/canvas-linux-x64-musl": "0.1.81", + "@napi-rs/canvas-win32-x64-msvc": "0.1.81" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.81.tgz", + "integrity": "sha512-78Lz+AUi+MsWupyZjXwpwQrp1QCwncPvRZrdvrROcZ9Gq9grP7LfQZiGdR8LKyHIq3OR18mDP+JESGT15V1nXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.81.tgz", + "integrity": "sha512-omejuKgHWKDGoh8rsgsyhm/whwxMaryTQjJTd9zD7hiB9/rzcEEJLHnzXWR5ysy4/tTjHaQotE6k2t8eodTLnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.81.tgz", + "integrity": "sha512-EYfk+co6BElq5DXNH9PBLYDYwc4QsvIVbyrsVHsxVpn4p6Y3/s8MChgC69AGqj3vzZBQ1qx2CRCMtg5cub+XuQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.81.tgz", + "integrity": "sha512-teh6Q74CyAcH31yLNQGR9MtXSFxlZa5CI6vvNUISI14gWIJWrhOwUAOly+KRe1aztWR0FWTVSPxM4p5y+06aow==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.81.tgz", + "integrity": "sha512-AGEopHFYRzJOjxY+2G1RmHPRnuWvO3Qdhq7sIazlSjxb3Z6dZHg7OB/4ZimXaimPjDACm9qWa6t5bn9bhXvkcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.81.tgz", + "integrity": "sha512-Bj3m1cl4GIhsigkdwOxii4g4Ump3/QhNpx85IgAlCCYXpaly6mcsWpuDYEabfIGWOWhDUNBOndaQUPfWK1czOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.81.tgz", + "integrity": "sha512-yg/5NkHykVdwPlD3XObwCa/EswkOwLHswJcI9rHrac+znHsmCSj5AMX/RTU9Z9F6lZTwL60JM2Esit33XhAMiw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.81.tgz", + "integrity": "sha512-tPfMpSEBuV5dJSKexO/UZxpOqnYTaNbG8aKa1ek8QsWu+4SJ/foWkaxscra/RUv85vepx6WWDjzBNbNJsTnO0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.81.tgz", + "integrity": "sha512-1L0xnYgzqn8Baef+inPvY4dKqdmw3KCBoe0NEDgezuBZN7MA5xElwifoG8609uNdrMtJ9J6QZarsslLRVqri7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.81.tgz", + "integrity": "sha512-57ryVbhm/z7RE9/UVcS7mrLPdlayLesy+9U0Uf6epCoeSGrs99tfieCcgZWFbIgmByQ1AZnNtFI2N6huqDLlWQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2672,6 +2859,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -3616,6 +3812,41 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3820,6 +4051,18 @@ "node": ">=8" } }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3964,6 +4207,35 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-pdf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.2.0.tgz", + "integrity": "sha512-zk0DIL31oCh8cuQycM0SJKfwh4Onz0/Nwi6wTOjgtEjWGUY6eM+/vuzvOP3j70qtEULn7m1JtaeGzud1w5fY2Q==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4259,6 +4531,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4573,6 +4851,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index b159a98..fe92cc4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,9 +17,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.543.0", + "pdfjs-dist": "^5.4.296", "react": "^19.1.1", "react-dom": "^19.1.1", "react-dropzone": "^14.3.8", + "react-pdf": "^10.2.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "zustand": "^5.0.8" diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 8bfc15a..077cdc8 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -3,22 +3,23 @@ import { useFileStore } from '@/stores/fileStore' import { api } from '@/services/api' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow } from '@/components/ui/table' import { Checkbox } from '@/components/ui/checkbox' import { FileUpload } from './FileUpload' import { DeleteConfirmDialog } from './DeleteConfirmDialog' -import { - Upload, - Download, - Trash2, - Search, +import { PDFPreviewModal } from './PDFPreviewModal' +import { + Upload, + Download, + Trash2, + Search, FileText, Eye, MessageSquare @@ -44,6 +45,13 @@ export function Dashboard() { const [deleting, setDeleting] = useState(false) const [downloading, setDownloading] = useState(false) + // Estados para el modal de preview de PDF + const [previewModalOpen, setPreviewModalOpen] = useState(false) + const [previewFileUrl, setPreviewFileUrl] = useState(null) + const [previewFileName, setPreviewFileName] = useState('') + const [previewFileTema, setPreviewFileTema] = useState(undefined) + const [loadingPreview, setLoadingPreview] = useState(false) + useEffect(() => { loadFiles() }, [selectedTema]) @@ -119,7 +127,7 @@ export function Dashboard() { // Descargar archivos seleccionados const handleDownloadMultiple = async () => { if (selectedFiles.size === 0) return - + try { setDownloading(true) const filesToDownload = Array.from(selectedFiles) @@ -132,6 +140,39 @@ export function Dashboard() { } } + // Abrir preview de PDF + const handlePreviewFile = async (filename: string, tema?: string) => { + // Solo permitir preview de archivos PDF + if (!filename.toLowerCase().endsWith('.pdf')) { + console.log('Solo se pueden previsualizar archivos PDF') + return + } + + try { + setLoadingPreview(true) + setPreviewFileName(filename) + setPreviewFileTema(tema) + + // Obtener la URL temporal (SAS) para el archivo + const url = await api.getPreviewUrl(filename, tema) + + setPreviewFileUrl(url) + setPreviewModalOpen(true) + } catch (error) { + console.error('Error obteniendo URL de preview:', error) + alert('Error al cargar la vista previa del archivo') + } finally { + setLoadingPreview(false) + } + } + + // Manejar descarga desde el modal de preview + const handleDownloadFromPreview = async () => { + if (previewFileName) { + await handleDownloadSingle(previewFileName) + } + } + const filteredFiles = files.filter(file => file.name.toLowerCase().includes(searchTerm.toLowerCase()) ) @@ -267,59 +308,75 @@ export function Dashboard() { - {filteredFiles.map((file) => ( - - - toggleFileSelection(file.name)} - /> - - {file.name} - {formatFileSize(file.size)} - {formatDate(file.last_modified)} - - - {file.tema || 'General'} - - - -
- - - - -
-
-
- ))} + {filteredFiles.map((file) => { + const isPDF = file.name.toLowerCase().endsWith('.pdf') + + return ( + + + toggleFileSelection(file.name)} + /> + + + {isPDF ? ( + + ) : ( + {file.name} + )} + + {formatFileSize(file.size)} + {formatDate(file.last_modified)} + + + {file.tema || 'General'} + + + +
+ + + + +
+
+
+ ) + })}
)} @@ -340,6 +397,15 @@ export function Dashboard() { loading={deleting} {...getDeleteDialogProps()} /> + + {/* PDF Preview Modal */} + ) } \ No newline at end of file diff --git a/frontend/src/components/FileUpload.tsx b/frontend/src/components/FileUpload.tsx index b9a2f71..b649d98 100644 --- a/frontend/src/components/FileUpload.tsx +++ b/frontend/src/components/FileUpload.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useState, useEffect } from 'react' import { useDropzone } from 'react-dropzone' import { useFileStore } from '@/stores/fileStore' import { api } from '@/services/api' @@ -20,6 +20,12 @@ export function FileUpload({ open, onOpenChange, onSuccess }: FileUploadProps) { const [tema, setTema] = useState(selectedTema || '') const [uploading, setUploading] = useState(false) + useEffect(() => { + if (open && selectedTema) { + setTema(selectedTema) + } + }, [open, selectedTema]) + const onDrop = useCallback((acceptedFiles: File[]) => { setFiles(prev => [...prev, ...acceptedFiles]) }, []) @@ -66,10 +72,9 @@ export function FileUpload({ open, onOpenChange, onSuccess }: FileUploadProps) { setTema('') onOpenChange(false) - // Aquí deberías recargar la lista de archivos + // recargar la lista de archivos onSuccess?.() - // Puedes agregar una función en el store para esto } catch (error) { console.error('Error uploading files:', error) diff --git a/frontend/src/components/PDFPreviewModal.tsx b/frontend/src/components/PDFPreviewModal.tsx new file mode 100644 index 0000000..2002a5d --- /dev/null +++ b/frontend/src/components/PDFPreviewModal.tsx @@ -0,0 +1,155 @@ +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { + Download, + Loader2, + FileText, + ExternalLink +} from 'lucide-react' + +interface PDFPreviewModalProps { + open: boolean + onOpenChange: (open: boolean) => void + fileUrl: string | null + fileName: string + onDownload?: () => void +} + +export function PDFPreviewModal({ + open, + onOpenChange, + fileUrl, + fileName, + onDownload +}: PDFPreviewModalProps) { + // Estado para manejar el loading del iframe + const [loading, setLoading] = useState(true) + + // Efecto para manejar el timeout del loading + useEffect(() => { + if (open && fileUrl) { + setLoading(true) + + // Timeout para ocultar loading automáticamente después de 3 segundos + // Algunos iframes no disparan onLoad correctamente + const timeout = setTimeout(() => { + setLoading(false) + }, 3000) + + return () => clearTimeout(timeout) + } + }, [open, fileUrl]) + + // Manejar cuando el iframe termina de cargar + const handleIframeLoad = () => { + setLoading(false) + } + + // Abrir PDF en nueva pestaña + const openInNewTab = () => { + if (fileUrl) { + window.open(fileUrl, '_blank') + } + } + + // Reiniciar loading cuando cambia el archivo + const handleOpenChange = (open: boolean) => { + if (open) { + setLoading(true) + } + onOpenChange(open) + } + + return ( + + + + + + {fileName} + + + Vista previa del documento PDF + + + + {/* Barra de controles */} +
+
+ +
+ + {/* Botón de descarga */} + {onDownload && ( + + )} +
+ + {/* Área de visualización del PDF con iframe */} +
+ {!fileUrl ? ( +
+
+ +

No se ha proporcionado un archivo para previsualizar

+
+
+ ) : ( + <> + {/* Indicador de carga */} + {loading && ( +
+
+ +

Cargando PDF...

+
+
+ )} + + {/* + Iframe para mostrar el PDF + El navegador maneja toda la visualización, zoom, scroll, etc. + Esto muestra el PDF exactamente como se vería si lo abrieras directamente + */} +