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>
)
}

View File

@@ -3,7 +3,6 @@ import { useQueryClient } from "@tanstack/react-query";
import { useRouter } from "@tanstack/react-router"; import { useRouter } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Trash } from "lucide-react";
import { carrerasKeys } from "@/routes/_authenticated/carreras"; import { carrerasKeys } from "@/routes/_authenticated/carreras";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../ui/dialog";

View File

@@ -57,7 +57,7 @@ export function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdd
if (!canIA) return if (!canIA) return
setSaving(true) setSaving(true)
try { try {
const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/generar/asignatura", { const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }), body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),

View File

@@ -14,7 +14,7 @@ export function AdjustAIButton({ plan }: { plan: PlanFull }) {
async function apply() { async function apply() {
setLoading(true) setLoading(true)
await fetch('https://genesis-engine.apps.lci.ulsa.mx/api/mejorar/plan', { await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/mejorar/plan`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, plan_id: plan.id }), body: JSON.stringify({ prompt, plan_id: plan.id }),
}).catch(() => { }) }).catch(() => { })

View File

@@ -1,7 +1,7 @@
// api.ts // api.ts
const API_BASE = const API_BASE =
(import.meta.env.VITE_API_BASE?.replace(/\/$/, "")) || (import.meta.env.VITE_API_BASE?.replace(/\/$/, "")) ||
"https://genesis-engine.apps.lci.ulsa.mx"; // 👈 tu Bun.serve real `${import.meta.env.VITE_BACK_ORIGIN}`; // 👈 tu Bun.serve real
export async function postAPI<T=any>(path: string, body: any): Promise<T> { export async function postAPI<T=any>(path: string, body: any): Promise<T> {
const url = `${API_BASE}${path}`; const url = `${API_BASE}${path}`;

View File

@@ -13,21 +13,9 @@ import {
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select" import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
import { DetailDialog } from "@/components/archivos/DetailDialog"
type RefRow = { import type { RefRow } from "@/types/RefRow"
fine_tuning_referencias_id: string
titulo_archivo: string | null
descripcion: string | null
contenido_archivo: any | null
tipo_contenido: string | null
fecha_subida: string | null
procesado: boolean | null
tags: string[] | null
fuente_autoridad: string | null
interno: boolean | null
instrucciones: string
created_by: string | null
}
export const Route = createFileRoute("/_authenticated/archivos")({ export const Route = createFileRoute("/_authenticated/archivos")({
component: RouteComponent, component: RouteComponent,
@@ -200,63 +188,6 @@ function RouteComponent() {
) )
} }
/* ========= Detalle ========= */
function DetailDialog({ row, onClose }: { row: RefRow | null; onClose: () => void }) {
return (
<Dialog open={!!row} 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 && row.tags.length > 0 && (
<div className="text-xs text-neutral-600">
<span className="font-medium">Tags: </span>{row.tags.join(", ")}
</div>
)}
<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 (JSON)</Label>
<pre className="mt-1 rounded-xl border bg-neutral-950 text-neutral-100 p-3 max-h-[360px] overflow-auto text-xs">
{JSON.stringify(row.contenido_archivo ?? {}, null, 2)}
</pre>
</div>
</div>
)}
<DialogFooter>
<Button onClick={onClose}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/* ========= Subida ========= */ /* ========= Subida ========= */
function UploadDialog({ function UploadDialog({
open, onOpenChange, onDone, open, onOpenChange, onDone,
@@ -284,7 +215,7 @@ function UploadDialog({
try { try {
const fileBase64 = await toBase64(file) const fileBase64 = await toBase64(file)
// Enviamos al motor (inserta en la tabla si insert=true) // Enviamos al motor (inserta en la tabla si insert=true)
const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/upload/documento", { const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/upload/documento`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -504,7 +504,7 @@ function MejorarAIButton({ asignaturaId, onApply }: {
async function apply() { async function apply() {
setLoading(true) setLoading(true)
try { try {
const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/mejorar/asignatura", { const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/mejorar/asignatura`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ asignatura_id: asignaturaId, prompt, insert }), body: JSON.stringify({ asignatura_id: asignaturaId, prompt, insert }),

View File

@@ -182,7 +182,7 @@ function Page() {
Plan Plan
</div> </div>
<DialogHeader className="p-0"> <DialogHeader className="p-0">
<DialogTitle className="font-mono" className="truncate text-xl sm:text-2xl">{planNombre}</DialogTitle> <DialogTitle className="font-mono truncate text-xl sm:text-2xl">{planNombre}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs"> <div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<KpiChip icon={Icons.BookOpen} label="Asignaturas" value={kpis.total} /> <KpiChip icon={Icons.BookOpen} label="Asignaturas" value={kpis.total} />

View File

@@ -270,7 +270,7 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
async function apply() { async function apply() {
setLoading(true) setLoading(true)
await fetch('https://genesis-engine.apps.lci.ulsa.mx/api/mejorar/plan', { await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/mejorar/plan`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, plan_id: plan.id }), body: JSON.stringify({ prompt, plan_id: plan.id }),
}).catch(() => { }) }).catch(() => { })
@@ -462,7 +462,7 @@ function AddAsignaturaButton({ planId, onAdded }: { planId: string; onAdded?: ()
if (!canIA) return if (!canIA) return
setSaving(true) setSaving(true)
try { try {
const res = await fetch("https://genesis-engine.apps.lci.ulsa.mx/api/generar/asignatura", { const res = await fetch(`${import.meta.env.VITE_BACK_ORIGIN}/api/generar/asignatura`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }), body: JSON.stringify({ planEstudiosId: planId, prompt: iaPrompt, semestre: iaSemestre.trim() ? Number(iaSemestre) : undefined, insert: true }),

14
src/types/RefRow.ts Normal file
View File

@@ -0,0 +1,14 @@
export type RefRow = {
fine_tuning_referencias_id: string
titulo_archivo: string | null
descripcion: string | null
s3_file_path: string | null // Added this property to match the API requirements.
tipo_contenido: string | null
fecha_subida: string | null
procesado: boolean | null
tags: string[] | null
fuente_autoridad: string | null
interno: boolean | null
instrucciones: string
created_by: string | null
}