289 lines
9.3 KiB
TypeScript
289 lines
9.3 KiB
TypeScript
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, A–Z, 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" /> A–Z
|
||
</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)
|
||
}
|