vista de pdf

This commit is contained in:
Sebastian
2025-10-29 07:50:50 +00:00
parent 46c07568bc
commit df2c184814
13 changed files with 795 additions and 102 deletions

View File

@@ -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"]
# 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"]

View File

@@ -602,3 +602,54 @@ async def get_file_info(
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)}")

View File

@@ -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,10 +22,41 @@ 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:
"""
Crear el container si no existe
@@ -261,6 +292,88 @@ class AzureBlobService:
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()

View File

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

18
frontend/Dockerfile Normal file
View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import {
import { Checkbox } from '@/components/ui/checkbox'
import { FileUpload } from './FileUpload'
import { DeleteConfirmDialog } from './DeleteConfirmDialog'
import { PDFPreviewModal } from './PDFPreviewModal'
import {
Upload,
Download,
@@ -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<string | null>(null)
const [previewFileName, setPreviewFileName] = useState('')
const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(undefined)
const [loadingPreview, setLoadingPreview] = useState(false)
useEffect(() => {
loadFiles()
}, [selectedTema])
@@ -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,7 +308,10 @@ export function Dashboard() {
</TableRow>
</TableHeader>
<TableBody>
{filteredFiles.map((file) => (
{filteredFiles.map((file) => {
const isPDF = file.name.toLowerCase().endsWith('.pdf')
return (
<TableRow key={file.full_path}>
<TableCell>
<Checkbox
@@ -275,7 +319,19 @@ export function Dashboard() {
onCheckedChange={() => toggleFileSelection(file.name)}
/>
</TableCell>
<TableCell className="font-medium">{file.name}</TableCell>
<TableCell className="font-medium">
{isPDF ? (
<button
onClick={() => handlePreviewFile(file.name, file.tema || undefined)}
className="text-blue-600 hover:text-blue-800 hover:underline text-left transition-colors"
disabled={loadingPreview}
>
{file.name}
</button>
) : (
<span>{file.name}</span>
)}
</TableCell>
<TableCell>{formatFileSize(file.size)}</TableCell>
<TableCell>{formatDate(file.last_modified)}</TableCell>
<TableCell>
@@ -319,7 +375,8 @@ export function Dashboard() {
</div>
</TableCell>
</TableRow>
))}
)
})}
</TableBody>
</Table>
)}
@@ -340,6 +397,15 @@ export function Dashboard() {
loading={deleting}
{...getDeleteDialogProps()}
/>
{/* PDF Preview Modal */}
<PDFPreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
fileUrl={previewFileUrl}
fileName={previewFileName}
onDownload={handleDownloadFromPreview}
/>
</div>
)
}

View File

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

View File

@@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-6xl max-h-[95vh] h-[95vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
{fileName}
</DialogTitle>
<DialogDescription>
Vista previa del documento PDF
</DialogDescription>
</DialogHeader>
{/* Barra de controles */}
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b bg-gray-50">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={openInNewTab}
title="Abrir en nueva pestaña"
>
<ExternalLink className="w-4 h-4 mr-2" />
Abrir en pestaña nueva
</Button>
</div>
{/* Botón de descarga */}
{onDownload && (
<Button
variant="outline"
size="sm"
onClick={onDownload}
title="Descargar archivo"
>
<Download className="w-4 h-4 mr-2" />
Descargar
</Button>
)}
</div>
{/* Área de visualización del PDF con iframe */}
<div className="flex-1 relative bg-gray-100">
{!fileUrl ? (
<div className="flex items-center justify-center h-full text-center text-gray-500 p-8">
<div>
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-400" />
<p>No se ha proporcionado un archivo para previsualizar</p>
</div>
</div>
) : (
<>
{/* Indicador de carga */}
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white z-10">
<div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-blue-500 mx-auto mb-4" />
<p className="text-gray-600">Cargando PDF...</p>
</div>
</div>
)}
{/*
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
*/}
<iframe
src={fileUrl}
className="w-full h-full border-0"
title={`Vista previa de ${fileName}`}
onLoad={handleIframeLoad}
style={{ minHeight: '600px' }}
/>
</>
)}
</div>
{/* Footer con información */}
<div className="px-6 py-3 border-t bg-gray-50 text-xs text-gray-500 text-center">
{fileName}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -175,4 +175,17 @@ export const api = {
window.URL.revokeObjectURL(downloadUrl)
},
// Obtener URL temporal para preview de archivos
getPreviewUrl: async (filename: string, tema?: string): Promise<string> => {
const url = tema
? `${API_BASE_URL}/files/${encodeURIComponent(filename)}/preview-url?tema=${encodeURIComponent(tema)}`
: `${API_BASE_URL}/files/${encodeURIComponent(filename)}/preview-url`
const response = await fetch(url)
if (!response.ok) throw new Error('Error getting preview URL')
const data = await response.json()
return data.url
},
}