This repository has been archived on 2026-01-21. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Acad-IA/src/components/asignaturas/EditBibliografíaButton.tsx

289 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react"
import { supabase,useSupabaseAuth } from "@/auth/supabase"
import { Button } from "../ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { Textarea } from "../ui/textarea"
import { Separator } from "../ui/separator"
import { ScrollArea } from "../ui/scroll-area"
import { Badge } from "../ui/badge"
import * as Icons from "lucide-react"
import { toast } from "sonner"
/**
* EditBibliografiaButton v3 (simple y aireado)
* - Layout limpio: una sola columna + barra mínima.
* - Acciones esenciales: Recortar, Dedupe, AZ, Importar, Copiar.
* - Toasts con sonner: toast.success / toast.error.
*/
export function EditBibliografiaButton({
asignaturaId,
value,
onSaved,
}: {
asignaturaId: string
value: string[]
onSaved: (refs: string[]) => void
}) {
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [text, setText] = useState("")
const auth = useSupabaseAuth()
const initialTextRef = useRef("")
const lines = useMemo(() => parseLines(text), [text])
const dirty = useMemo(() => initialTextRef.current !== text, [text])
// 🔹 Abre el editor y carga los valores actuales
function openEditor() {
const start = (value ?? []).join("\n")
setText(start)
initialTextRef.current = start
setOpen(true)
}
// ✅ Función para generar diferencias tipo JSON Patch
function generateDiff(oldRefs: string[], newRefs: string[]) {
const changes: any[] = []
// Si son distintos en contenido o longitud
if (JSON.stringify(oldRefs) !== JSON.stringify(newRefs)) {
changes.push({
op: "replace",
path: "/bibliografia",
from: oldRefs,
value: newRefs,
})
}
return changes
}
async function save() {
setSaving(true)
try {
// 1⃣ Obtener bibliografía anterior
const { data: oldData, error: oldError } = await supabase
.from("asignaturas")
.select("bibliografia")
.eq("id", asignaturaId)
.maybeSingle()
if (oldError) throw oldError
const oldRefs = oldData?.bibliografia ?? []
const newRefs = parseLines(text)
// 2⃣ Generar diferencias
const diff = generateDiff(oldRefs, newRefs)
// 3⃣ Guardar respaldo si hay cambios
if (diff.length > 0) {
const { error: backupError } = await supabase
.from("historico_cambios_asignaturas") // misma tabla de respaldo
.insert({
id_asignatura: asignaturaId,
json_cambios: diff, // jsonb
user_id: auth.user?.id,
created_at: new Date().toISOString(),
})
if (backupError) throw backupError
}
// 4⃣ Actualizar bibliografía en asignaturas
const { data, error } = await supabase
.from("asignaturas")
.update({ bibliografia: newRefs })
.eq("id", asignaturaId)
.select()
.maybeSingle()
if (error) throw error
// 5⃣ Refrescar estado local
onSaved((data as any)?.bibliografia ?? newRefs)
initialTextRef.current = newRefs.join("\n")
toast.success(`${newRefs.length} referencia(s) guardada(s).`)
setOpen(false)
} catch (err: any) {
toast.error(err.message ?? "No se pudo guardar la bibliografía")
} finally {
setSaving(false)
}
}
// 🔧 Acciones extra
function actionTrim() {
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
setText(next.join("\n"))
}
function actionDedupe() {
const seen = new Set<string>()
const next: string[] = []
for (const l of parseLines(text)) {
const k = l.toLowerCase()
if (!seen.has(k)) {
seen.add(k)
next.push(l)
}
}
setText(next.join("\n"))
}
function actionSort() {
const next = [...parseLines(text)].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base" }),
)
setText(next.join("\n"))
}
async function actionImportClipboard() {
try {
const clip = await navigator.clipboard.readText()
if (!clip) {
toast("Portapapeles vacío")
return
}
const next = [...parseLines(text), ...parseLines(clip)]
setText(next.join("\n"))
toast.success("Texto importado")
} catch (e: any) {
toast.error(e?.message ?? "No se pudo leer el portapapeles")
}
}
async function actionExportClipboard() {
try {
await navigator.clipboard.writeText(parseLines(text).join("\n"))
toast.success("Copiado al portapapeles")
} catch (e: any) {
toast.error(e?.message ?? "No se pudo copiar")
}
}
// ⌨️ Atajo Ctrl+S
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!open) return
if (e.key === "s" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
if (!saving && dirty) void save()
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, saving, dirty, text])
return (
<>
<Button size="sm" variant="outline" onClick={openEditor} className="gap-2">
<Icons.Library className="h-4 w-4" /> Editar bibliografía
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-xl p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6">
<div className="flex items-center justify-between gap-3">
<div>
<DialogTitle className="tracking-tight">Editar bibliografía</DialogTitle>
<DialogDescription>
Una referencia por línea. Guarda con <kbd className="border px-1 rounded">Ctrl/+S</kbd>.
</DialogDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="rounded-full">{lines.length}</Badge>
{dirty ? <Badge variant="destructive" className="rounded-full">Sin guardar</Badge> : <Badge className="rounded-full">OK</Badge>}
</div>
</div>
</DialogHeader>
{/* Barra de acciones (compacta) */}
<div className="px-6 pb-2">
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" className="gap-2" onClick={actionTrim}>
<Icons.Scissors className="h-4 w-4" /> Recortar
</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={actionDedupe}>
<Icons.CopyMinus className="h-4 w-4" /> Quitar duplicados
</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={actionSort}>
<Icons.SortAsc className="h-4 w-4" /> AZ
</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={actionImportClipboard}>
<Icons.ClipboardPaste className="h-4 w-4" /> Importar
</Button>
<Button size="sm" variant="outline" className="gap-2" onClick={actionExportClipboard}>
<Icons.ClipboardCopy className="h-4 w-4" /> Copiar
</Button>
</div>
</div>
<Separator />
{/* Editor */}
<div className="p-6">
<Textarea
className="min-h-[320px] resize-y"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Autor, Título, Editorial, Año\nDOI/URL\n…`}
/>
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>Consejo: pega desde tu gestor y usa los botones para limpiar.</span>
<span>{new Intl.NumberFormat().format(text.length)} caracteres</span>
</div>
</div>
{/* Previsualización (compacta) */}
{lines.length > 0 && (
<>
<Separator />
<div className="px-6 pb-6">
<div className="text-sm font-medium mb-2 flex items-center gap-2">
<Icons.Eye className="h-4 w-4" /> Vista previa
</div>
<ScrollArea className="h-40 rounded-md border">
<ol className="list-decimal pl-6 pr-3 py-3 space-y-1 text-sm">
{lines.map((l, i) => (
<li key={i} className="text-muted-foreground">{l}</li>
))}
</ol>
</ScrollArea>
</div>
</>
)}
<Separator />
<DialogFooter className="px-6 py-4">
<Button variant="outline" onClick={() => setOpen(false)} className="gap-2">
<Icons.X className="h-4 w-4" /> Cerrar
</Button>
<Button onClick={save} disabled={saving || !dirty} className="gap-2">
{saving ? <Icons.Loader2 className="h-4 w-4 animate-spin" /> : <Icons.Save className="h-4 w-4" />}
{saving ? "Guardando…" : "Guardar"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/* ---------- Utilidades ---------- */
function parseLines(text: string): string[] {
return text
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean)
}