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.
This commit is contained in:
2025-09-01 14:58:36 -06:00
parent 5181306b93
commit 1808ce6f81
13 changed files with 762 additions and 99 deletions

View File

@@ -0,0 +1,228 @@
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)
}