From df2c1848140b572a422f5751dc8e28bef363bdb6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 29 Oct 2025 07:50:50 +0000 Subject: [PATCH] vista de pdf --- backend/{dockerfile => Dockerfile} | 3 +- .../routers/__pycache__/files.cpython-312.pyc | Bin 23617 -> 25673 bytes backend/app/routers/files.py | 57 +++- .../__pycache__/azure_service.cpython-312.pyc | Bin 11098 -> 15578 bytes backend/app/services/azure_service.py | 127 +++++++- docker-compose.yml | 20 +- frontend/Dockerfile | 18 ++ frontend/package-lock.json | 287 ++++++++++++++++++ frontend/package.json | 2 + frontend/src/components/Dashboard.tsx | 198 ++++++++---- frontend/src/components/FileUpload.tsx | 11 +- frontend/src/components/PDFPreviewModal.tsx | 155 ++++++++++ frontend/src/services/api.ts | 19 +- 13 files changed, 795 insertions(+), 102 deletions(-) rename backend/{dockerfile => Dockerfile} (67%) create mode 100644 frontend/Dockerfile create mode 100644 frontend/src/components/PDFPreviewModal.tsx diff --git a/backend/dockerfile b/backend/Dockerfile similarity index 67% rename from backend/dockerfile rename to backend/Dockerfile index 84f6e6b..1a1a245 100644 --- a/backend/dockerfile +++ b/backend/Dockerfile @@ -19,4 +19,5 @@ RUN uv sync --frozen EXPOSE 8000 # Comando para desarrollo (con reload) -CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file +# Reinstalar dependencias al inicio para asegurar compatibilidad con volumen montado +CMD ["sh", "-c", "uv sync --frozen && uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"] \ No newline at end of file diff --git a/backend/app/routers/__pycache__/files.cpython-312.pyc b/backend/app/routers/__pycache__/files.cpython-312.pyc index fcafacdfa86fe4d4207a08fb6c9416d7ee5b9b3f..4acffca3a2727871acab4f3d6ab4dec7529c770e 100644 GIT binary patch delta 2021 zcmaJ>T}%{L6uvXFyDYmb3L-2BdPRX<6cM2c#99%55EW5Cs;Fe#xw}r?omub9A|jSH zHK``1wVj8WRBcWBVlZi%O;euQMNDJUpA40@+toHr+O+klOEs~5>$$Ul)Q3(oXU?2^ z&bjA&=VyG3`}_`<`-R8jLh$_fDJS>!y^-tXe)_OVvl*NDBCf``&zEfC5wS~aq*{2_ z-mDw7F>Qo`ytIr|OZmi2s*B-YT1Og#4ypc}ebhQf=up#Xo4+A`&9=|ZrQ+%A7dd-z ztr(5QFS*YYf+VMWT;Pg~uPasy_lj2-eC2)tm8^{SR^Ar4yGCiX&l2yhF0;hp>h@gj zknwS28GprN+;8j^P;q5^LsNbhcg<+uygFyf)0!Aemaceb&y_vK#mzn0sI2<2fO5BT z#)HkDWHH9{w8lSZ`Q5?&X}r~T)M;7VE2}cHc8b|(sc%L*y7Ts&_Qv)l4(>PO`OY@s z7l`pj=TR#d_r9QGWT>e2Oi&IJMU0TMwNXk2WOAw|M#J<4Kp<8Hq)nxwh9!ar#IP)p zA;yM9+_Qf-_C+SH56Tf0uWRr>@Pe*ipo8#9@gb7p!z7hM2BR`P-G5w-Q7s_A(d?5A z%qs}$@bJ9x)!Jtq!~9KtR4~q#7jc5Iw66Z3{i>jF5;p{Tqg=*{LsHf-8Wx6m$vMow zjihYgTqm|7M0v^0v_Y10)h>Ci*t^gMbaB%Pq&sJCT#0Bthjh2$>|UOiGf&!d)~4GG zkFU(=?e;Ek&z?vH(qcW&nDiAHMH}!)VeBIwAs2>c2T=$WDUK-_7<3Ye0!U%(>uTxp z6M+uF{;8g?-iBJsT%-o43nUa7G6;|L}A{vB4!dMXp zNJx}aiZxlmVoZl)IUok)iMX=Ha=SZ|o>jvUmW39LP26C;5XQR9lExq+0XYbEl8ONY ztdUTR%91Fp$z<9pYO2*eF->i@yw>0YPhr!!b-M;={3oax)jg`FGpOYeXf=kDWZ?G^AxhVa1&7{$|{vMvhSi&>V7>Yo?@= zk%-ugy9pFUQWXZErcYeQv16IcpO|2u8V$&*B8D@iVb=49pc(()Jk`~#Yrx0Zz^%5> zlt>UNr{X&QNCwXR1gfsg3%|W0%_oh~H3qyt~S`Q3_TWUkdA`%qp@XvjVE3HB5aCBeg*M*|Zcssf8IE zo%Cy>)(@M@FniW|66`2zRx<5hJW>!MdOs_6|BP#ewb^`20P{t^!H0B-B4&h z!u{&4_};sI(z`xcxHOr!_|1~b`N`snM_IOF*JET`0U+$wei}QeU!o(OzUd83c6ON&)!Z93qbJQGI_-8Wbfu9a+m+6P`~G=^jV}+>&4@ax`pd7H&1$ z8ULIBr~c-+)_aOul@eePS!moq4*?TIH!%dA8zZU|3zJRsJNRX5SbHt*KDbIak>)XX zgiCMd5bu1oN-QqPOw7}F$q!1@PsuMz)K5xG&Q8rs*?gb7S`4T>TInD&qs(MkH64x{ zlFFYLI5{;Z`=|vm1L@5R)s8ST-k6-Gr2&y+;?$iyQA?H?NN?V#<;BOCIQfr-EXPdV zPYhg~K8%y4EyGzrjLoH%LhOtyCU-dKh+XD&{=gu>DSM#ehK%xj-5yL zF_;zwrfCuLTkaxH_;}|EO4Txg_F*fT3rm-<;T5J}K zFK1L-$?zEzmPK|z;unWaZhlH>PO4qeX&{#oh>P_$Z%)u==Kjc}z!=L|bP6a0023W_ Avj6}9 diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index b074299..60735a7 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -582,7 +582,7 @@ async def get_file_info( blob_name=filename, tema=tema or "" ) - + # Convertir a objeto FileInfo file_info = FileInfo( name=file_data["name"], @@ -593,12 +593,63 @@ async def get_file_info( content_type=file_data.get("content_type"), url=file_data.get("url") ) - + logger.info(f"Información obtenida para archivo '{filename}'") return file_info - + except FileNotFoundError: raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado") except Exception as e: logger.error(f"Error obteniendo info del archivo '{filename}': {e}") + raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}") + + +@router.get("/{filename}/preview-url") +async def get_file_preview_url( + filename: str, + tema: Optional[str] = Query(None, description="Tema donde está el archivo"), + expiry_hours: int = Query(1, description="Horas de validez de la URL (máximo 24)", ge=1, le=24) +): + """ + Generar una URL temporal (SAS) para vista previa de archivos + + Este endpoint genera una URL con firma temporal (Shared Access Signature) + que permite acceder al archivo directamente desde el navegador sin autenticación. + La URL expira después del tiempo especificado por seguridad. + + Casos de uso: + - Vista previa de PDFs en el navegador + - Mostrar imágenes sin descargarlas + - Compartir acceso temporal a archivos + + Args: + filename: Nombre del archivo + tema: Tema donde está ubicado el archivo (opcional) + expiry_hours: Horas de validez de la URL (1-24 horas, por defecto 1) + + Returns: + JSON con la URL temporal del archivo + """ + try: + # Generar SAS URL usando el servicio de Azure + sas_url = await azure_service.generate_sas_url( + blob_name=filename, + tema=tema or "", + expiry_hours=expiry_hours + ) + + logger.info(f"SAS URL generada para preview de '{filename}'" + (f" del tema '{tema}'" if tema else "")) + + return { + "success": True, + "filename": filename, + "url": sas_url, + "expiry_hours": expiry_hours, + "message": f"URL temporal generada (válida por {expiry_hours} hora{'s' if expiry_hours > 1 else ''})" + } + + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Archivo '{filename}' no encontrado") + except Exception as e: + logger.error(f"Error generando preview URL para '{filename}': {e}") raise HTTPException(status_code=500, detail=f"Error interno del servidor: {str(e)}") \ No newline at end of file diff --git a/backend/app/services/__pycache__/azure_service.cpython-312.pyc b/backend/app/services/__pycache__/azure_service.cpython-312.pyc index dffc02fa3755defe223d8858179c3960a29558b6..0173b959c407360b4bd232d10f55a9dfa32a6be0 100644 GIT binary patch delta 6016 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 bool: """ @@ -237,30 +268,112 @@ class AzureBlobService: async def get_download_url(self, blob_name: str, tema: str = "") -> str: """ Obtener URL de descarga directa para un archivo - + Args: blob_name: Nombre del archivo tema: Tema/carpeta donde está el archivo - + Returns: str: URL de descarga """ try: # Construir la ruta completa full_blob_name = f"{tema}/{blob_name}" if tema else blob_name - + # Obtener cliente del blob blob_client = self.blob_service_client.get_blob_client( container=self.container_name, blob=full_blob_name ) - + return blob_client.url - + except Exception as e: logger.error(f"Error obteniendo URL de descarga para '{blob_name}': {e}") raise e + async def generate_sas_url(self, blob_name: str, tema: str = "", expiry_hours: int = 1) -> str: + """ + Generar una URL SAS (Shared Access Signature) temporal para acceder a un archivo + + Esta URL permite acceso temporal y seguro al archivo sin requerir autenticación. + Es ideal para vistas previas de archivos en el navegador. + + Args: + blob_name: Nombre del archivo + tema: Tema/carpeta donde está el archivo + expiry_hours: Horas de validez de la URL (por defecto 1 hora) + + Returns: + str: URL completa con SAS token para acceso temporal + """ + try: + from azure.storage.blob import ContentSettings + + # Construir la ruta completa del blob + full_blob_name = f"{tema}/{blob_name}" if tema else blob_name + + # Obtener cliente del blob + blob_client = self.blob_service_client.get_blob_client( + container=self.container_name, + blob=full_blob_name + ) + + # Verificar que el archivo existe antes de generar el SAS + if not blob_client.exists(): + raise FileNotFoundError(f"El archivo '{blob_name}' no existe") + + # IMPORTANTE: Configurar el blob para que se muestre inline (no descarga) + # Esto hace que el navegador muestre el PDF en lugar de descargarlo + try: + content_settings = ContentSettings( + content_type='application/pdf', + content_disposition='inline' # Clave para mostrar en navegador + ) + blob_client.set_http_headers(content_settings=content_settings) + logger.info(f"Headers configurados para visualización inline de '{full_blob_name}'") + except Exception as e: + logger.warning(f"No se pudieron configurar headers inline: {e}") + + # Definir el tiempo de expiración del SAS token + start_time = datetime.now(timezone.utc) + expiry_time = start_time + timedelta(hours=expiry_hours) + + # Extraer la account key del connection string para generar el SAS + # El SAS necesita la account key para firmar el token + account_key = None + for part in settings.AZURE_STORAGE_CONNECTION_STRING.split(';'): + if part.startswith('AccountKey='): + account_key = part.split('=', 1)[1] + break + + if not account_key: + raise ValueError("No se pudo extraer AccountKey del connection string") + + # Generar el SAS token con permisos de solo lectura + sas_token = generate_blob_sas( + account_name=blob_client.account_name, + container_name=self.container_name, + blob_name=full_blob_name, + account_key=account_key, + permission=BlobSasPermissions(read=True), # Solo permisos de lectura + expiry=expiry_time, + start=start_time + ) + + # Construir la URL completa con el SAS token + sas_url = f"{blob_client.url}?{sas_token}" + + logger.info(f"SAS URL generada para '{full_blob_name}' (válida por {expiry_hours} horas)") + return sas_url + + except FileNotFoundError: + logger.error(f"Archivo '{full_blob_name}' no encontrado para generar SAS") + raise + except Exception as e: + logger.error(f"Error generando SAS URL para '{blob_name}': {e}") + raise e + # Instancia global del servicio azure_service = AzureBlobService() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a3fed3c..9a9c58d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: frontend: build: ./frontend @@ -24,25 +22,9 @@ services: volumes: - ./backend:/app - /app/.venv - - depends_on: - - qdrant - networks: - - app-network - - qdrant: - image: qdrant/qdrant:latest - ports: - - "6333:6333" - - "6334:6334" - volumes: - - qdrant_storage:/qdrant/storage networks: - app-network networks: app-network: - driver: bridge - -volumes: - qdrant_storage: \ No newline at end of file + driver: bridge \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..b801fcc --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Copy application files +COPY . . + +# Install dependencies (will be done at runtime due to volume mount) +RUN npm install + +# Expose Vite dev server port +EXPOSE 5173 + +# Run development server with host binding for Docker +CMD ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4aea2c0..32c2d10 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,9 +15,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.543.0", + "pdfjs-dist": "^5.4.296", "react": "^19.1.1", "react-dom": "^19.1.1", "react-dropzone": "^14.3.8", + "react-pdf": "^10.2.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "zustand": "^5.0.8" @@ -982,6 +984,191 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.81.tgz", + "integrity": "sha512-ReCjd5SYI/UKx/olaQLC4GtN6wUQGjlgHXs1lvUvWGXfBMR3Fxnik3cL+OxKN5ithNdoU0/GlCrdKcQDFh2XKQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.81", + "@napi-rs/canvas-darwin-arm64": "0.1.81", + "@napi-rs/canvas-darwin-x64": "0.1.81", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.81", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.81", + "@napi-rs/canvas-linux-arm64-musl": "0.1.81", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.81", + "@napi-rs/canvas-linux-x64-gnu": "0.1.81", + "@napi-rs/canvas-linux-x64-musl": "0.1.81", + "@napi-rs/canvas-win32-x64-msvc": "0.1.81" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.81.tgz", + "integrity": "sha512-78Lz+AUi+MsWupyZjXwpwQrp1QCwncPvRZrdvrROcZ9Gq9grP7LfQZiGdR8LKyHIq3OR18mDP+JESGT15V1nXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.81.tgz", + "integrity": "sha512-omejuKgHWKDGoh8rsgsyhm/whwxMaryTQjJTd9zD7hiB9/rzcEEJLHnzXWR5ysy4/tTjHaQotE6k2t8eodTLnA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.81.tgz", + "integrity": "sha512-EYfk+co6BElq5DXNH9PBLYDYwc4QsvIVbyrsVHsxVpn4p6Y3/s8MChgC69AGqj3vzZBQ1qx2CRCMtg5cub+XuQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.81.tgz", + "integrity": "sha512-teh6Q74CyAcH31yLNQGR9MtXSFxlZa5CI6vvNUISI14gWIJWrhOwUAOly+KRe1aztWR0FWTVSPxM4p5y+06aow==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.81.tgz", + "integrity": "sha512-AGEopHFYRzJOjxY+2G1RmHPRnuWvO3Qdhq7sIazlSjxb3Z6dZHg7OB/4ZimXaimPjDACm9qWa6t5bn9bhXvkcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.81.tgz", + "integrity": "sha512-Bj3m1cl4GIhsigkdwOxii4g4Ump3/QhNpx85IgAlCCYXpaly6mcsWpuDYEabfIGWOWhDUNBOndaQUPfWK1czOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.81.tgz", + "integrity": "sha512-yg/5NkHykVdwPlD3XObwCa/EswkOwLHswJcI9rHrac+znHsmCSj5AMX/RTU9Z9F6lZTwL60JM2Esit33XhAMiw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.81.tgz", + "integrity": "sha512-tPfMpSEBuV5dJSKexO/UZxpOqnYTaNbG8aKa1ek8QsWu+4SJ/foWkaxscra/RUv85vepx6WWDjzBNbNJsTnO0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.81.tgz", + "integrity": "sha512-1L0xnYgzqn8Baef+inPvY4dKqdmw3KCBoe0NEDgezuBZN7MA5xElwifoG8609uNdrMtJ9J6QZarsslLRVqri7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.81", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.81.tgz", + "integrity": "sha512-57ryVbhm/z7RE9/UVcS7mrLPdlayLesy+9U0Uf6epCoeSGrs99tfieCcgZWFbIgmByQ1AZnNtFI2N6huqDLlWQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2672,6 +2859,15 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -3616,6 +3812,41 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3820,6 +4051,18 @@ "node": ">=8" } }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3964,6 +4207,35 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-pdf": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.2.0.tgz", + "integrity": "sha512-zk0DIL31oCh8cuQycM0SJKfwh4Onz0/Nwi6wTOjgtEjWGUY6eM+/vuzvOP3j70qtEULn7m1JtaeGzud1w5fY2Q==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4259,6 +4531,12 @@ "node": ">=18" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -4573,6 +4851,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index b159a98..fe92cc4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,9 +17,11 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.543.0", + "pdfjs-dist": "^5.4.296", "react": "^19.1.1", "react-dom": "^19.1.1", "react-dropzone": "^14.3.8", + "react-pdf": "^10.2.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", "zustand": "^5.0.8" diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 8bfc15a..077cdc8 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -3,22 +3,23 @@ import { useFileStore } from '@/stores/fileStore' import { api } from '@/services/api' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow } from '@/components/ui/table' import { Checkbox } from '@/components/ui/checkbox' import { FileUpload } from './FileUpload' import { DeleteConfirmDialog } from './DeleteConfirmDialog' -import { - Upload, - Download, - Trash2, - Search, +import { PDFPreviewModal } from './PDFPreviewModal' +import { + Upload, + Download, + Trash2, + Search, FileText, Eye, MessageSquare @@ -44,6 +45,13 @@ export function Dashboard() { const [deleting, setDeleting] = useState(false) const [downloading, setDownloading] = useState(false) + // Estados para el modal de preview de PDF + const [previewModalOpen, setPreviewModalOpen] = useState(false) + const [previewFileUrl, setPreviewFileUrl] = useState(null) + const [previewFileName, setPreviewFileName] = useState('') + const [previewFileTema, setPreviewFileTema] = useState(undefined) + const [loadingPreview, setLoadingPreview] = useState(false) + useEffect(() => { loadFiles() }, [selectedTema]) @@ -119,7 +127,7 @@ export function Dashboard() { // Descargar archivos seleccionados const handleDownloadMultiple = async () => { if (selectedFiles.size === 0) return - + try { setDownloading(true) const filesToDownload = Array.from(selectedFiles) @@ -132,6 +140,39 @@ export function Dashboard() { } } + // Abrir preview de PDF + const handlePreviewFile = async (filename: string, tema?: string) => { + // Solo permitir preview de archivos PDF + if (!filename.toLowerCase().endsWith('.pdf')) { + console.log('Solo se pueden previsualizar archivos PDF') + return + } + + try { + setLoadingPreview(true) + setPreviewFileName(filename) + setPreviewFileTema(tema) + + // Obtener la URL temporal (SAS) para el archivo + const url = await api.getPreviewUrl(filename, tema) + + setPreviewFileUrl(url) + setPreviewModalOpen(true) + } catch (error) { + console.error('Error obteniendo URL de preview:', error) + alert('Error al cargar la vista previa del archivo') + } finally { + setLoadingPreview(false) + } + } + + // Manejar descarga desde el modal de preview + const handleDownloadFromPreview = async () => { + if (previewFileName) { + await handleDownloadSingle(previewFileName) + } + } + const filteredFiles = files.filter(file => file.name.toLowerCase().includes(searchTerm.toLowerCase()) ) @@ -267,59 +308,75 @@ export function Dashboard() { - {filteredFiles.map((file) => ( - - - toggleFileSelection(file.name)} - /> - - {file.name} - {formatFileSize(file.size)} - {formatDate(file.last_modified)} - - - {file.tema || 'General'} - - - -
- - - - -
-
-
- ))} + {filteredFiles.map((file) => { + const isPDF = file.name.toLowerCase().endsWith('.pdf') + + return ( + + + toggleFileSelection(file.name)} + /> + + + {isPDF ? ( + + ) : ( + {file.name} + )} + + {formatFileSize(file.size)} + {formatDate(file.last_modified)} + + + {file.tema || 'General'} + + + +
+ + + + +
+
+
+ ) + })}
)} @@ -340,6 +397,15 @@ export function Dashboard() { loading={deleting} {...getDeleteDialogProps()} /> + + {/* PDF Preview Modal */} + ) } \ No newline at end of file diff --git a/frontend/src/components/FileUpload.tsx b/frontend/src/components/FileUpload.tsx index b9a2f71..b649d98 100644 --- a/frontend/src/components/FileUpload.tsx +++ b/frontend/src/components/FileUpload.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useState, useEffect } from 'react' import { useDropzone } from 'react-dropzone' import { useFileStore } from '@/stores/fileStore' import { api } from '@/services/api' @@ -20,6 +20,12 @@ export function FileUpload({ open, onOpenChange, onSuccess }: FileUploadProps) { const [tema, setTema] = useState(selectedTema || '') const [uploading, setUploading] = useState(false) + useEffect(() => { + if (open && selectedTema) { + setTema(selectedTema) + } + }, [open, selectedTema]) + const onDrop = useCallback((acceptedFiles: File[]) => { setFiles(prev => [...prev, ...acceptedFiles]) }, []) @@ -66,10 +72,9 @@ export function FileUpload({ open, onOpenChange, onSuccess }: FileUploadProps) { setTema('') onOpenChange(false) - // Aquí deberías recargar la lista de archivos + // recargar la lista de archivos onSuccess?.() - // Puedes agregar una función en el store para esto } catch (error) { console.error('Error uploading files:', error) diff --git a/frontend/src/components/PDFPreviewModal.tsx b/frontend/src/components/PDFPreviewModal.tsx new file mode 100644 index 0000000..2002a5d --- /dev/null +++ b/frontend/src/components/PDFPreviewModal.tsx @@ -0,0 +1,155 @@ +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { + Download, + Loader2, + FileText, + ExternalLink +} from 'lucide-react' + +interface PDFPreviewModalProps { + open: boolean + onOpenChange: (open: boolean) => void + fileUrl: string | null + fileName: string + onDownload?: () => void +} + +export function PDFPreviewModal({ + open, + onOpenChange, + fileUrl, + fileName, + onDownload +}: PDFPreviewModalProps) { + // Estado para manejar el loading del iframe + const [loading, setLoading] = useState(true) + + // Efecto para manejar el timeout del loading + useEffect(() => { + if (open && fileUrl) { + setLoading(true) + + // Timeout para ocultar loading automáticamente después de 3 segundos + // Algunos iframes no disparan onLoad correctamente + const timeout = setTimeout(() => { + setLoading(false) + }, 3000) + + return () => clearTimeout(timeout) + } + }, [open, fileUrl]) + + // Manejar cuando el iframe termina de cargar + const handleIframeLoad = () => { + setLoading(false) + } + + // Abrir PDF en nueva pestaña + const openInNewTab = () => { + if (fileUrl) { + window.open(fileUrl, '_blank') + } + } + + // Reiniciar loading cuando cambia el archivo + const handleOpenChange = (open: boolean) => { + if (open) { + setLoading(true) + } + onOpenChange(open) + } + + return ( + + + + + + {fileName} + + + Vista previa del documento PDF + + + + {/* Barra de controles */} +
+
+ +
+ + {/* Botón de descarga */} + {onDownload && ( + + )} +
+ + {/* Área de visualización del PDF con iframe */} +
+ {!fileUrl ? ( +
+
+ +

No se ha proporcionado un archivo para previsualizar

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

Cargando PDF...

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