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 EXPOSE 8000
# Comando para desarrollo (con reload) # 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

@@ -582,7 +582,7 @@ async def get_file_info(
blob_name=filename, blob_name=filename,
tema=tema or "" tema=tema or ""
) )
# Convertir a objeto FileInfo # Convertir a objeto FileInfo
file_info = FileInfo( file_info = FileInfo(
name=file_data["name"], name=file_data["name"],
@@ -593,12 +593,63 @@ async def get_file_info(
content_type=file_data.get("content_type"), content_type=file_data.get("content_type"),
url=file_data.get("url") url=file_data.get("url")
) )
logger.info(f"Información obtenida para archivo '{filename}'") logger.info(f"Información obtenida para archivo '{filename}'")
return file_info return file_info
except FileNotFoundError: except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado") raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado")
except Exception as e: except Exception as e:
logger.error(f"Error obteniendo info del archivo '{filename}': {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)}") 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 azure.core.exceptions import ResourceNotFoundError, ResourceExistsError
from typing import List, Optional, BinaryIO from typing import List, Optional, BinaryIO
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
import os import os
from ..core.config import settings from ..core.config import settings
@@ -22,9 +22,40 @@ class AzureBlobService:
) )
self.container_name = settings.AZURE_CONTAINER_NAME self.container_name = settings.AZURE_CONTAINER_NAME
logger.info(f"Cliente de Azure Blob Storage inicializado para container: {self.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: except Exception as e:
logger.error(f"Error inicializando Azure Blob Service: {e}") logger.error(f"Error inicializando Azure Blob Service: {e}")
raise 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: 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: async def get_download_url(self, blob_name: str, tema: str = "") -> str:
""" """
Obtener URL de descarga directa para un archivo Obtener URL de descarga directa para un archivo
Args: Args:
blob_name: Nombre del archivo blob_name: Nombre del archivo
tema: Tema/carpeta donde está el archivo tema: Tema/carpeta donde está el archivo
Returns: Returns:
str: URL de descarga str: URL de descarga
""" """
try: try:
# Construir la ruta completa # Construir la ruta completa
full_blob_name = f"{tema}/{blob_name}" if tema else blob_name full_blob_name = f"{tema}/{blob_name}" if tema else blob_name
# Obtener cliente del blob # Obtener cliente del blob
blob_client = self.blob_service_client.get_blob_client( blob_client = self.blob_service_client.get_blob_client(
container=self.container_name, container=self.container_name,
blob=full_blob_name blob=full_blob_name
) )
return blob_client.url return blob_client.url
except Exception as e: except Exception as e:
logger.error(f"Error obteniendo URL de descarga para '{blob_name}': {e}") logger.error(f"Error obteniendo URL de descarga para '{blob_name}': {e}")
raise 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 # Instancia global del servicio
azure_service = AzureBlobService() azure_service = AzureBlobService()

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
frontend: frontend:
build: ./frontend build: ./frontend
@@ -24,25 +22,9 @@ services:
volumes: volumes:
- ./backend:/app - ./backend:/app
- /app/.venv - /app/.venv
depends_on:
- qdrant
networks:
- app-network
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant_storage:/qdrant/storage
networks: networks:
- app-network - app-network
networks: networks:
app-network: app-network:
driver: bridge 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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.543.0", "lucide-react": "^0.543.0",
"pdfjs-dist": "^5.4.296",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-pdf": "^10.2.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.8" "zustand": "^5.0.8"
@@ -982,6 +984,191 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "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==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true "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": { "node_modules/detect-libc": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -3616,6 +3812,41 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3820,6 +4051,18 @@
"node": ">=8" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3964,6 +4207,35 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4259,6 +4531,12 @@
"node": ">=18" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4573,6 +4851,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -17,9 +17,11 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.543.0", "lucide-react": "^0.543.0",
"pdfjs-dist": "^5.4.296",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-pdf": "^10.2.0",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.8" "zustand": "^5.0.8"

View File

@@ -3,22 +3,23 @@ import { useFileStore } from '@/stores/fileStore'
import { api } from '@/services/api' import { api } from '@/services/api'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow TableRow
} from '@/components/ui/table' } from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { FileUpload } from './FileUpload' import { FileUpload } from './FileUpload'
import { DeleteConfirmDialog } from './DeleteConfirmDialog' import { DeleteConfirmDialog } from './DeleteConfirmDialog'
import { import { PDFPreviewModal } from './PDFPreviewModal'
Upload, import {
Download, Upload,
Trash2, Download,
Search, Trash2,
Search,
FileText, FileText,
Eye, Eye,
MessageSquare MessageSquare
@@ -44,6 +45,13 @@ export function Dashboard() {
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [downloading, setDownloading] = 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(() => { useEffect(() => {
loadFiles() loadFiles()
}, [selectedTema]) }, [selectedTema])
@@ -119,7 +127,7 @@ export function Dashboard() {
// Descargar archivos seleccionados // Descargar archivos seleccionados
const handleDownloadMultiple = async () => { const handleDownloadMultiple = async () => {
if (selectedFiles.size === 0) return if (selectedFiles.size === 0) return
try { try {
setDownloading(true) setDownloading(true)
const filesToDownload = Array.from(selectedFiles) 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 => const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase()) file.name.toLowerCase().includes(searchTerm.toLowerCase())
) )
@@ -267,59 +308,75 @@ export function Dashboard() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredFiles.map((file) => ( {filteredFiles.map((file) => {
<TableRow key={file.full_path}> const isPDF = file.name.toLowerCase().endsWith('.pdf')
<TableCell>
<Checkbox return (
checked={selectedFiles.has(file.name)} <TableRow key={file.full_path}>
onCheckedChange={() => toggleFileSelection(file.name)} <TableCell>
/> <Checkbox
</TableCell> checked={selectedFiles.has(file.name)}
<TableCell className="font-medium">{file.name}</TableCell> onCheckedChange={() => toggleFileSelection(file.name)}
<TableCell>{formatFileSize(file.size)}</TableCell> />
<TableCell>{formatDate(file.last_modified)}</TableCell> </TableCell>
<TableCell> <TableCell className="font-medium">
<span className="px-2 py-1 bg-gray-100 rounded-md text-sm"> {isPDF ? (
{file.tema || 'General'} <button
</span> onClick={() => handlePreviewFile(file.name, file.tema || undefined)}
</TableCell> className="text-blue-600 hover:text-blue-800 hover:underline text-left transition-colors"
<TableCell> disabled={loadingPreview}
<div className="flex gap-1"> >
<Button {file.name}
variant="ghost" </button>
size="sm" ) : (
onClick={() => handleDownloadSingle(file.name)} <span>{file.name}</span>
disabled={downloading} )}
title="Descargar archivo" </TableCell>
> <TableCell>{formatFileSize(file.size)}</TableCell>
<Download className="w-4 h-4" /> <TableCell>{formatDate(file.last_modified)}</TableCell>
</Button> <TableCell>
<Button <span className="px-2 py-1 bg-gray-100 rounded-md text-sm">
variant="ghost" {file.tema || 'General'}
size="sm" </span>
title="Ver chunks" </TableCell>
> <TableCell>
<Eye className="w-4 h-4" /> <div className="flex gap-1">
</Button> <Button
<Button variant="ghost"
variant="ghost" size="sm"
size="sm" onClick={() => handleDownloadSingle(file.name)}
title="Generar preguntas" disabled={downloading}
> title="Descargar archivo"
<MessageSquare className="w-4 h-4" /> >
</Button> <Download className="w-4 h-4" />
<Button </Button>
variant="ghost" <Button
size="sm" variant="ghost"
onClick={() => handleDeleteSingle(file.name)} size="sm"
title="Eliminar archivo" title="Ver chunks"
> >
<Trash2 className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Button> </Button>
</div> <Button
</TableCell> variant="ghost"
</TableRow> size="sm"
))} title="Generar preguntas"
>
<MessageSquare className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSingle(file.name)}
title="Eliminar archivo"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody> </TableBody>
</Table> </Table>
)} )}
@@ -340,6 +397,15 @@ export function Dashboard() {
loading={deleting} loading={deleting}
{...getDeleteDialogProps()} {...getDeleteDialogProps()}
/> />
{/* PDF Preview Modal */}
<PDFPreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
fileUrl={previewFileUrl}
fileName={previewFileName}
onDownload={handleDownloadFromPreview}
/>
</div> </div>
) )
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react' import { useCallback, useState, useEffect } from 'react'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { useFileStore } from '@/stores/fileStore' import { useFileStore } from '@/stores/fileStore'
import { api } from '@/services/api' import { api } from '@/services/api'
@@ -20,6 +20,12 @@ export function FileUpload({ open, onOpenChange, onSuccess }: FileUploadProps) {
const [tema, setTema] = useState(selectedTema || '') const [tema, setTema] = useState(selectedTema || '')
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
useEffect(() => {
if (open && selectedTema) {
setTema(selectedTema)
}
}, [open, selectedTema])
const onDrop = useCallback((acceptedFiles: File[]) => { const onDrop = useCallback((acceptedFiles: File[]) => {
setFiles(prev => [...prev, ...acceptedFiles]) setFiles(prev => [...prev, ...acceptedFiles])
}, []) }, [])
@@ -66,10 +72,9 @@ export function FileUpload({ open, onOpenChange, onSuccess }: FileUploadProps) {
setTema('') setTema('')
onOpenChange(false) onOpenChange(false)
// Aquí deberías recargar la lista de archivos // recargar la lista de archivos
onSuccess?.() onSuccess?.()
// Puedes agregar una función en el store para esto
} catch (error) { } catch (error) {
console.error('Error uploading files:', 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

@@ -159,15 +159,15 @@ export const api = {
downloadTema: async (tema: string): Promise<void> => { downloadTema: async (tema: string): Promise<void> => {
const response = await fetch(`${API_BASE_URL}/files/tema/${encodeURIComponent(tema)}/download-all`) const response = await fetch(`${API_BASE_URL}/files/tema/${encodeURIComponent(tema)}/download-all`)
if (!response.ok) throw new Error('Error downloading tema') if (!response.ok) throw new Error('Error downloading tema')
const blob = await response.blob() const blob = await response.blob()
const downloadUrl = window.URL.createObjectURL(blob) const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = downloadUrl link.href = downloadUrl
const contentDisposition = response.headers.get('Content-Disposition') const contentDisposition = response.headers.get('Content-Disposition')
const filename = contentDisposition?.split('filename=')[1]?.replace(/"/g, '') || `${tema}.zip` const filename = contentDisposition?.split('filename=')[1]?.replace(/"/g, '') || `${tema}.zip`
link.download = filename link.download = filename
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
@@ -175,4 +175,17 @@ export const api = {
window.URL.revokeObjectURL(downloadUrl) 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
},
} }