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
Alejandro Rosales 1808ce6f81 feat: add EditAsignaturaButton and EditBibliografiaButton components for managing asignaturas
- Introduced EditAsignaturaButton for editing asignatura details with a dialog interface.
- Added EditBibliografiaButton for managing bibliographic references with various utility actions (trim, dedupe, sort, import, export).
- Created reusable Field, Section, and Stat components for better UI structure.
- Implemented typeStyle utility for styling based on asignatura type.
- Integrated new components into the existing asignatura route.
- Updated package.json to include new dependencies for alert dialogs and tooltips.
- Defined Asignatura type in a new types file for better type safety.
2025-09-01 14:58:36 -06:00

229 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 } 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 initialTextRef = useRef("")
const lines = useMemo(() => parseLines(text), [text])
const dirty = useMemo(() => initialTextRef.current !== text, [text])
function openEditor() {
const start = (value ?? []).join("\n")
setText(start)
initialTextRef.current = start
setOpen(true)
}
async function save() {
try {
setSaving(true)
const refs = parseLines(text)
const { data, error } = await supabase
.from("asignaturas")
.update({ bibliografia: refs })
.eq("id", asignaturaId)
.select()
.maybeSingle()
if (error) throw error
onSaved((data as any)?.bibliografia ?? refs)
initialTextRef.current = refs.join("\n")
toast.success(`${refs.length} referencia(s) guardada(s).`)
setOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "No se pudo guardar")
} finally {
setSaving(false)
}
}
// Acciones
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 guardar
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)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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)
}