feat: implement DetailDialog component for PDF viewing and downloading; refactor API calls to use environment variables

This commit is contained in:
2025-09-04 07:38:58 -06:00
parent 1808ce6f81
commit 2367baa538
10 changed files with 195 additions and 80 deletions

View File

@@ -0,0 +1,171 @@
import { useEffect, useState } from "react"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import * as Icons from "lucide-react"
import type { RefRow } from "@/types/RefRow"
// POST -> recibe blob PDF y (opcional) Content-Disposition
async function fetchPdfBlob(url: string, body: { s3_file_path: string }) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await res.text())
const blob = await res.blob()
return { blob, disposition: res.headers.get("content-disposition") ?? undefined }
}
function filenameFromDisposition(disposition?: string, fallback = "archivo.pdf") {
if (!disposition) return fallback
const star = /filename\*\=UTF-8''([^;]+)/i.exec(disposition)?.[1]
if (star) return decodeURIComponent(star)
const simple = /filename\=\"?([^\";]+)\"?/i.exec(disposition)?.[1]
return simple ?? fallback
}
export function DetailDialog({
row,
onClose,
pdfUrl = "/api/get/documento", // ← permite inyectar el endpoint
}: {
row: RefRow | null
onClose: () => void
pdfUrl?: string
}) {
const [viewerUrl, setViewerUrl] = useState<string | null>(null)
const [currentBlob, setCurrentBlob] = useState<Blob | null>(null)
const [filename, setFilename] = useState<string>("archivo.pdf")
const open = !!row
useEffect(() => {
if (!open) return
let revoked = false
let urlToRevoke: string | null = null
const ctrl = new AbortController()
async function load() {
if (!row?.s3_file_path) {
setViewerUrl(null)
setCurrentBlob(null)
return
}
try {
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { s3_file_path: row.s3_file_path })
if (ctrl.signal.aborted) return
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
setFilename(name)
const url = URL.createObjectURL(blob)
urlToRevoke = url
if (!revoked) {
setViewerUrl(url)
setCurrentBlob(blob)
}
} catch (e) {
console.error("Carga de PDF falló:", e)
setViewerUrl(null)
setCurrentBlob(null)
}
}
load()
return () => {
revoked = true
ctrl.abort()
if (urlToRevoke) URL.revokeObjectURL(urlToRevoke)
}
}, [open, row?.s3_file_path, row?.titulo_archivo, pdfUrl])
async function downloadFile() {
try {
// Si ya tenemos el blob, úsalo
if (currentBlob) {
const link = document.createElement("a")
const href = URL.createObjectURL(currentBlob)
link.href = href
link.download = filename
link.click()
URL.revokeObjectURL(href)
return
}
// Si no, vuelve a pedirlo (p. ej., si el user abre y descarga sin render previo)
if (!row?.s3_file_path) throw new Error("No hay contenido para descargar.")
const { blob, disposition } = await fetchPdfBlob(`${import.meta.env.VITE_BACK_ORIGIN}/api/get/documento`, { s3_file_path: row.s3_file_path })
const name = row.titulo_archivo ? `${row.titulo_archivo}.pdf` : filenameFromDisposition(disposition)
const link = document.createElement("a")
const href = URL.createObjectURL(blob)
link.href = href
link.download = name
link.click()
URL.revokeObjectURL(href)
} catch (error) {
console.error("Error al descargar el archivo:", error)
alert("No se pudo descargar el archivo.")
}
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="font-mono">{row?.titulo_archivo ?? "(Sin título)"}</DialogTitle>
<DialogDescription>{row?.descripcion || "Sin descripción"}</DialogDescription>
</DialogHeader>
{row && (
<div className="space-y-3">
<div className="text-xs flex flex-wrap gap-2">
<Badge variant="outline">{row.tipo_contenido ?? "—"}</Badge>
<Badge variant="outline">{row.interno ? "Interno" : "Externo"}</Badge>
<Badge variant="outline">{row.procesado ? "Procesado" : "Pendiente"}</Badge>
{row.fuente_autoridad && <Badge variant="outline">{row.fuente_autoridad}</Badge>}
{row.fecha_subida && (
<span className="inline-flex items-center gap-1">
<Icons.CalendarClock className="w-3 h-3" />
{new Date(row.fecha_subida).toLocaleString()}
</span>
)}
</div>
{row.tags?.length ? (
<div className="text-xs text-neutral-600">
<span className="font-medium">Tags: </span>
{row.tags.join(", ")}
</div>
) : null}
<div>
<Label className="text-xs text-neutral-600">Instrucciones</Label>
<div className="mt-1 rounded-xl border bg-white/60 p-3 text-sm whitespace-pre-wrap">
{row.instrucciones || "—"}
</div>
</div>
<div>
<Label className="text-xs text-neutral-600">Contenido (PDF)</Label>
{viewerUrl ? (
<iframe
className="mt-1 rounded-xl border bg-neutral-950 text-neutral-100 w-full h-[360px]"
src={viewerUrl}
title="PDF Viewer"
/>
) : (
<div className="mt-1 rounded-xl border p-3 text-sm text-neutral-600">No se pudo cargar el PDF.</div>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={downloadFile}>Descargar</Button>
<Button onClick={onClose}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}