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

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Copy application files
COPY . .
# Install dependencies (will be done at runtime due to volume mount)
RUN npm install
# Expose Vite dev server port
EXPOSE 5173
# Run development server with host binding for Docker
CMD ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0"]

View File

@@ -15,9 +15,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.543.0",
"pdfjs-dist": "^5.4.296",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-pdf": "^10.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.8"
@@ -982,6 +984,191 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.81.tgz",
"integrity": "sha512-ReCjd5SYI/UKx/olaQLC4GtN6wUQGjlgHXs1lvUvWGXfBMR3Fxnik3cL+OxKN5ithNdoU0/GlCrdKcQDFh2XKQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.81",
"@napi-rs/canvas-darwin-arm64": "0.1.81",
"@napi-rs/canvas-darwin-x64": "0.1.81",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.81",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.81",
"@napi-rs/canvas-linux-arm64-musl": "0.1.81",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.81",
"@napi-rs/canvas-linux-x64-gnu": "0.1.81",
"@napi-rs/canvas-linux-x64-musl": "0.1.81",
"@napi-rs/canvas-win32-x64-msvc": "0.1.81"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.81.tgz",
"integrity": "sha512-78Lz+AUi+MsWupyZjXwpwQrp1QCwncPvRZrdvrROcZ9Gq9grP7LfQZiGdR8LKyHIq3OR18mDP+JESGT15V1nXw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.81.tgz",
"integrity": "sha512-omejuKgHWKDGoh8rsgsyhm/whwxMaryTQjJTd9zD7hiB9/rzcEEJLHnzXWR5ysy4/tTjHaQotE6k2t8eodTLnA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.81.tgz",
"integrity": "sha512-EYfk+co6BElq5DXNH9PBLYDYwc4QsvIVbyrsVHsxVpn4p6Y3/s8MChgC69AGqj3vzZBQ1qx2CRCMtg5cub+XuQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.81.tgz",
"integrity": "sha512-teh6Q74CyAcH31yLNQGR9MtXSFxlZa5CI6vvNUISI14gWIJWrhOwUAOly+KRe1aztWR0FWTVSPxM4p5y+06aow==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.81.tgz",
"integrity": "sha512-AGEopHFYRzJOjxY+2G1RmHPRnuWvO3Qdhq7sIazlSjxb3Z6dZHg7OB/4ZimXaimPjDACm9qWa6t5bn9bhXvkcw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.81.tgz",
"integrity": "sha512-Bj3m1cl4GIhsigkdwOxii4g4Ump3/QhNpx85IgAlCCYXpaly6mcsWpuDYEabfIGWOWhDUNBOndaQUPfWK1czOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.81.tgz",
"integrity": "sha512-yg/5NkHykVdwPlD3XObwCa/EswkOwLHswJcI9rHrac+znHsmCSj5AMX/RTU9Z9F6lZTwL60JM2Esit33XhAMiw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.81.tgz",
"integrity": "sha512-tPfMpSEBuV5dJSKexO/UZxpOqnYTaNbG8aKa1ek8QsWu+4SJ/foWkaxscra/RUv85vepx6WWDjzBNbNJsTnO0w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.81.tgz",
"integrity": "sha512-1L0xnYgzqn8Baef+inPvY4dKqdmw3KCBoe0NEDgezuBZN7MA5xElwifoG8609uNdrMtJ9J6QZarsslLRVqri7g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.81.tgz",
"integrity": "sha512-57ryVbhm/z7RE9/UVcS7mrLPdlayLesy+9U0Uf6epCoeSGrs99tfieCcgZWFbIgmByQ1AZnNtFI2N6huqDLlWQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2672,6 +2859,15 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -3616,6 +3812,41 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/make-cancellable-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
"node_modules/make-event-props": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz",
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
}
},
"node_modules/merge-refs": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz",
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3820,6 +4051,18 @@
"node": ">=8"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3964,6 +4207,35 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-pdf": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.2.0.tgz",
"integrity": "sha512-zk0DIL31oCh8cuQycM0SJKfwh4Onz0/Nwi6wTOjgtEjWGUY6eM+/vuzvOP3j70qtEULn7m1JtaeGzud1w5fY2Q==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"make-cancellable-promise": "^2.0.0",
"make-event-props": "^2.0.0",
"merge-refs": "^2.0.0",
"pdfjs-dist": "5.4.296",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4259,6 +4531,12 @@
"node": ">=18"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4573,6 +4851,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

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

View File

@@ -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<string | null>(null)
const [previewFileName, setPreviewFileName] = useState('')
const [previewFileTema, setPreviewFileTema] = useState<string | undefined>(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() {
</TableRow>
</TableHeader>
<TableBody>
{filteredFiles.map((file) => (
<TableRow key={file.full_path}>
<TableCell>
<Checkbox
checked={selectedFiles.has(file.name)}
onCheckedChange={() => toggleFileSelection(file.name)}
/>
</TableCell>
<TableCell className="font-medium">{file.name}</TableCell>
<TableCell>{formatFileSize(file.size)}</TableCell>
<TableCell>{formatDate(file.last_modified)}</TableCell>
<TableCell>
<span className="px-2 py-1 bg-gray-100 rounded-md text-sm">
{file.tema || 'General'}
</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadSingle(file.name)}
disabled={downloading}
title="Descargar archivo"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Ver chunks"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
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>
))}
{filteredFiles.map((file) => {
const isPDF = file.name.toLowerCase().endsWith('.pdf')
return (
<TableRow key={file.full_path}>
<TableCell>
<Checkbox
checked={selectedFiles.has(file.name)}
onCheckedChange={() => toggleFileSelection(file.name)}
/>
</TableCell>
<TableCell className="font-medium">
{isPDF ? (
<button
onClick={() => handlePreviewFile(file.name, file.tema || undefined)}
className="text-blue-600 hover:text-blue-800 hover:underline text-left transition-colors"
disabled={loadingPreview}
>
{file.name}
</button>
) : (
<span>{file.name}</span>
)}
</TableCell>
<TableCell>{formatFileSize(file.size)}</TableCell>
<TableCell>{formatDate(file.last_modified)}</TableCell>
<TableCell>
<span className="px-2 py-1 bg-gray-100 rounded-md text-sm">
{file.tema || 'General'}
</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadSingle(file.name)}
disabled={downloading}
title="Descargar archivo"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Ver chunks"
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
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>
</Table>
)}
@@ -340,6 +397,15 @@ export function Dashboard() {
loading={deleting}
{...getDeleteDialogProps()}
/>
{/* PDF Preview Modal */}
<PDFPreviewModal
open={previewModalOpen}
onOpenChange={setPreviewModalOpen}
fileUrl={previewFileUrl}
fileName={previewFileName}
onDownload={handleDownloadFromPreview}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,155 @@
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import {
Download,
Loader2,
FileText,
ExternalLink
} from 'lucide-react'
interface PDFPreviewModalProps {
open: boolean
onOpenChange: (open: boolean) => void
fileUrl: string | null
fileName: string
onDownload?: () => void
}
export function PDFPreviewModal({
open,
onOpenChange,
fileUrl,
fileName,
onDownload
}: PDFPreviewModalProps) {
// Estado para manejar el loading del iframe
const [loading, setLoading] = useState(true)
// Efecto para manejar el timeout del loading
useEffect(() => {
if (open && fileUrl) {
setLoading(true)
// Timeout para ocultar loading automáticamente después de 3 segundos
// Algunos iframes no disparan onLoad correctamente
const timeout = setTimeout(() => {
setLoading(false)
}, 3000)
return () => clearTimeout(timeout)
}
}, [open, fileUrl])
// Manejar cuando el iframe termina de cargar
const handleIframeLoad = () => {
setLoading(false)
}
// Abrir PDF en nueva pestaña
const openInNewTab = () => {
if (fileUrl) {
window.open(fileUrl, '_blank')
}
}
// Reiniciar loading cuando cambia el archivo
const handleOpenChange = (open: boolean) => {
if (open) {
setLoading(true)
}
onOpenChange(open)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-6xl max-h-[95vh] h-[95vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5" />
{fileName}
</DialogTitle>
<DialogDescription>
Vista previa del documento PDF
</DialogDescription>
</DialogHeader>
{/* Barra de controles */}
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b bg-gray-50">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={openInNewTab}
title="Abrir en nueva pestaña"
>
<ExternalLink className="w-4 h-4 mr-2" />
Abrir en pestaña nueva
</Button>
</div>
{/* Botón de descarga */}
{onDownload && (
<Button
variant="outline"
size="sm"
onClick={onDownload}
title="Descargar archivo"
>
<Download className="w-4 h-4 mr-2" />
Descargar
</Button>
)}
</div>
{/* Área de visualización del PDF con iframe */}
<div className="flex-1 relative bg-gray-100">
{!fileUrl ? (
<div className="flex items-center justify-center h-full text-center text-gray-500 p-8">
<div>
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-400" />
<p>No se ha proporcionado un archivo para previsualizar</p>
</div>
</div>
) : (
<>
{/* Indicador de carga */}
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white z-10">
<div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-blue-500 mx-auto mb-4" />
<p className="text-gray-600">Cargando PDF...</p>
</div>
</div>
)}
{/*
Iframe para mostrar el PDF
El navegador maneja toda la visualización, zoom, scroll, etc.
Esto muestra el PDF exactamente como se vería si lo abrieras directamente
*/}
<iframe
src={fileUrl}
className="w-full h-full border-0"
title={`Vista previa de ${fileName}`}
onLoad={handleIframeLoad}
style={{ minHeight: '600px' }}
/>
</>
)}
</div>
{/* Footer con información */}
<div className="px-6 py-3 border-t bg-gray-50 text-xs text-gray-500 text-center">
{fileName}
</div>
</DialogContent>
</Dialog>
)
}

View File

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