vista de pdf
This commit is contained in:
@@ -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"]
|
||||||
Binary file not shown.
@@ -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)}")
|
||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -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
18
frontend/Dockerfile
Normal 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"]
|
||||||
287
frontend/package-lock.json
generated
287
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
155
frontend/src/components/PDFPreviewModal.tsx
Normal file
155
frontend/src/components/PDFPreviewModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user