feat: add canvas-confetti integration and AuroraButton component; enhance UI with animations and improve button interactions
This commit is contained in:
23
src/components/effect/aurora-button.tsx
Normal file
23
src/components/effect/aurora-button.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
// components/ui/aurora-button.tsx
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function AuroraButton({
|
||||
loading,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button> & { loading?: boolean }) {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
className={`${props.className ?? ""} relative overflow-hidden`}
|
||||
disabled={props.disabled || loading}
|
||||
>
|
||||
{loading && (
|
||||
<span className="absolute inset-0 animate-aurora" />
|
||||
)}
|
||||
<span className="relative z-10">
|
||||
{loading ? "Pensando…" : children}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { supabase } from "@/auth/supabase"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import confetti from "canvas-confetti"
|
||||
|
||||
import {
|
||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
@@ -514,6 +516,11 @@ function MejorarAIButton({ asignaturaId, onApply }: {
|
||||
}
|
||||
const nuevo = await res.json()
|
||||
onApply(nuevo as Asignatura)
|
||||
confetti({
|
||||
particleCount: 120,
|
||||
spread: 80,
|
||||
origin: { y: 0.6 },
|
||||
})
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Error al mejorar la asignatura")
|
||||
@@ -547,7 +554,24 @@ function MejorarAIButton({ asignaturaId, onApply }: {
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={apply} disabled={!prompt.trim() || loading}>{loading ? "Aplicando…" : "Aplicar ajuste"}</Button>
|
||||
<Button
|
||||
onClick={apply}
|
||||
disabled={!prompt.trim() || loading}
|
||||
className={
|
||||
loading
|
||||
? "relative overflow-hidden text-white shadow-md"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="relative z-10">Pensando…</span>
|
||||
) : (
|
||||
"Aplicar ajuste"
|
||||
)}
|
||||
{loading && (
|
||||
<span className="absolute inset-0 animate-aurora" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -291,7 +291,7 @@ function RouteComponent() {
|
||||
// NEW: acciones carrito
|
||||
function addToCart(a: Asignatura) {
|
||||
setCart(prev => prev.find(x => x.id === a.id) ? prev : [...prev, a])
|
||||
toast.success('Asignatura añadida al carrito')
|
||||
toast.success('Asignatura añadida al carrito de asignaturas')
|
||||
}
|
||||
function removeFromCart(id: string) {
|
||||
setCart(prev => prev.filter(x => x.id !== id))
|
||||
@@ -355,7 +355,7 @@ function RouteComponent() {
|
||||
className="relative"
|
||||
>
|
||||
<Icons.ShoppingCart className="w-4 h-4 mr-2" />
|
||||
Carrito
|
||||
Carrito de asignaturas
|
||||
{cart.length > 0 && (
|
||||
<span className="ml-2 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-white/90 text-[11px] text-neutral-900 px-1">
|
||||
{cart.length}
|
||||
@@ -369,36 +369,51 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr,140px,180px,150px]">
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Buscar por nombre, clave, plan, carrera, facultad…"
|
||||
className="w-full"
|
||||
/>
|
||||
<Select value={sem} onValueChange={setSem}>
|
||||
<SelectTrigger><SelectValue placeholder="Semestre" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={tipo} onValueChange={setTipo}>
|
||||
<SelectTrigger><SelectValue placeholder="Tipo" /></SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
|
||||
<SelectTrigger><SelectValue placeholder="Agrupar por" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="semestre">Agrupar por semestre</SelectItem>
|
||||
<SelectItem value="ninguno">Sin agrupación</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<Label>Búsqueda</Label>
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Nombre, clave, plan, carrera, facultad…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Semestre</Label>
|
||||
<Select value={sem} onValueChange={setSem}>
|
||||
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{semestres.map(s => <SelectItem key={s} value={s}>Semestre {s}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Tipo</Label>
|
||||
<Select value={tipo} onValueChange={setTipo}>
|
||||
<SelectTrigger><SelectValue placeholder="Todos" /></SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="todos">Todos</SelectItem>
|
||||
{tipos.map(t => <SelectItem key={t} value={t}>{t}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Agrupación</Label>
|
||||
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as any)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="semestre">Por semestre</SelectItem>
|
||||
<SelectItem value="ninguno">Sin agrupación</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Chips de salud */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<HealthChip
|
||||
@@ -621,7 +636,7 @@ function RouteComponent() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" onClick={clearCart}><Icons.Trash2 className="w-4 h-4 mr-1" /> Vaciar carrito</Button>
|
||||
<Button variant="ghost" onClick={clearCart}><Icons.Trash2 className="w-4 h-4 mr-1" /> Vaciar carrito de Asignaturas</Button>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" onClick={() => setBulkOpen(false)}>Cerrar</Button>
|
||||
<Button onClick={cloneBulk}><Icons.CopyPlus className="w-4 h-4 mr-1" /> Clonar en lote</Button>
|
||||
@@ -686,7 +701,7 @@ function AsignaturaCard({ a, onClone, onAddToCart }: { a: Asignatura; onClone: (
|
||||
<Icons.Copy className="w-4 h-4" /> Clonar…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="gap-2" onClick={onAddToCart}>
|
||||
<Icons.ShoppingCart className="w-4 h-4" /> Añadir al carrito
|
||||
<Icons.ShoppingCart className="w-4 h-4" /> Añadir al carrito de asignaturas
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -171,10 +171,10 @@ function RouteComponent() {
|
||||
|
||||
{/* Métricas principales */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric to={`/_authenticated/carreras?facultadId=${facultad.id}`} label="Carreras" value={counts.carreras} Icon={Icons.GraduationCap} />
|
||||
<Metric to={`/_authenticated/planes?facultadId=${facultad.id}`} label="Planes de estudio" value={counts.planes} Icon={Icons.ScrollText} />
|
||||
<Metric to={`/_authenticated/asignaturas?facultadId=${facultad.id}`} label="Asignaturas" value={counts.asignaturas} Icon={Icons.BookOpen} />
|
||||
<Metric to={`/_authenticated/criterios?facultadId=${facultad.id}`} label="Criterios de carrera" value={counts.criterios} Icon={Icons.CheckCircle2} />
|
||||
<Metric to={`/carreras?facultadId=${facultad.id}`} label="Carreras" value={counts.carreras} Icon={Icons.GraduationCap} />
|
||||
<Metric to={`/planes?facultadId=${facultad.id}`} label="Planes de estudio" value={counts.planes} Icon={Icons.ScrollText} />
|
||||
<Metric to={`/asignaturas?facultadId=${facultad.id}`} label="Asignaturas" value={counts.asignaturas} Icon={Icons.BookOpen} />
|
||||
<Metric to={`/criterios?facultadId=${facultad.id}`} label="Criterios de carrera" value={counts.criterios} Icon={Icons.CheckCircle2} />
|
||||
</div>
|
||||
|
||||
{/* Calidad + Salud */}
|
||||
|
||||
@@ -13,6 +13,8 @@ import gsap from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { AcademicSections } from '@/components/planes/academic-sections'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs'
|
||||
import confetti from 'canvas-confetti'
|
||||
import { AuroraButton } from '@/components/effect/aurora-button'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
@@ -407,6 +409,8 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
|
||||
body: JSON.stringify({ prompt, plan_id: plan.id }),
|
||||
}).catch(() => { })
|
||||
setLoading(false)
|
||||
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -423,9 +427,9 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
|
||||
</DialogHeader>
|
||||
<Textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ej.: Enfatiza ciberseguridad y proyectos prácticos…" className="min-h-[120px]" />
|
||||
<DialogFooter>
|
||||
<Button onClick={apply} disabled={!prompt.trim() || loading}>
|
||||
<AuroraButton onClick={apply} disabled={!prompt.trim() || loading}>
|
||||
{loading ? 'Aplicando…' : 'Aplicar ajuste'}
|
||||
</Button>
|
||||
</AuroraButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -519,6 +523,8 @@ function AddAsignaturaButton({
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
confetti({ particleCount: 120, spread: 80, origin: { y: 0.6 } })
|
||||
|
||||
setOpen(false); onAdded?.()
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Error al generar la asignatura")
|
||||
@@ -616,11 +622,13 @@ function AddAsignaturaButton({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={submit} disabled={saving || !canSubmit}>
|
||||
<Button >
|
||||
</Button>
|
||||
<AuroraButton onClick={submit} disabled={saving || !canSubmit}>
|
||||
{saving
|
||||
? (mode === "manual" ? "Guardando…" : "Generando…")
|
||||
: (mode === "manual" ? "Crear" : "Generar e insertar")}
|
||||
</Button>
|
||||
</AuroraButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -118,7 +118,31 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes aurora {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-aurora {
|
||||
background: radial-gradient(at 20% 30%, rgba(59, 130, 246, .5), transparent 50%),
|
||||
radial-gradient(at 80% 70%, rgba(236, 72, 153, .5), transparent 50%),
|
||||
radial-gradient(at 50% 100%, rgba(34, 197, 94, .5), transparent 50%);
|
||||
background-size: 200% 200%;
|
||||
animation: aurora 6s ease infinite;
|
||||
filter: blur(12px) opacity(0.8);
|
||||
}
|
||||
Reference in New Issue
Block a user