Refactor App component styles and remove highlights section
- Updated background gradient colors for a lighter theme. - Changed button styles to improve visibility. - Removed the highlights section to streamline the layout. - Adjusted text colors for better contrast and readability.
This commit is contained in:
@@ -229,39 +229,37 @@ function Page() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NUEVO: botón de edición */}
|
||||
<div className="flex justify-end mb-2">
|
||||
<EditContenidosButton
|
||||
asignaturaId={a.id}
|
||||
value={a.contenidos as any}
|
||||
onSaved={(contenidos) => setA(s => ({ ...s, contenidos }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* …tu render flexible existente… */}
|
||||
{(() => {
|
||||
// --- helpers de normalización ---
|
||||
// helpers de normalización (como ya los tienes)
|
||||
const titleOf = (u: any): string => {
|
||||
// Si viene como arreglo [{titulo, seccion, subtemas}], u.temas tendrá pares [ 'titulo' , '…' ]
|
||||
const t = (u.temas || []).find(([k]: any[]) => String(k).toLowerCase() === "titulo")?.[1]
|
||||
if (typeof t === "string" && t.trim()) return t
|
||||
// Fallback: si la clave de la unidad es numérica, usa "Unidad N" o el título ya calculado
|
||||
return /^\s*\d+/.test(String(u.key))
|
||||
? (u.title && u.title !== u.key ? u.title : `Unidad ${u.key ? u.key : 1}`)
|
||||
: (u.title || String(u.key))
|
||||
}
|
||||
|
||||
const temasOf = (u: any): string[] => {
|
||||
const pairs: any[] = Array.isArray(u.temas) ? u.temas : []
|
||||
// 1) Estructura con subtemas
|
||||
const sub = pairs.find(([k]) => String(k).toLowerCase() === "subtemas")?.[1]
|
||||
if (Array.isArray(sub)) {
|
||||
// subtemas: ["t1", "t2", ...]
|
||||
return sub.map(String)
|
||||
}
|
||||
if (Array.isArray(sub)) return sub.map(String)
|
||||
if (sub && typeof sub === "object") {
|
||||
// subtemas: { "1": "t1", "2": "t2", ... }
|
||||
return Object.entries(sub)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([, v]) => String(v))
|
||||
return Object.entries(sub).sort(([a], [b]) => Number(a) - Number(b)).map(([, v]) => String(v))
|
||||
}
|
||||
// 2) Estructura plana numerada { "1": "t1", "2": "t2", ... }
|
||||
const numerados = pairs
|
||||
.filter(([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"))
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([, v]) => String(v))
|
||||
if (numerados.length) return numerados
|
||||
// 3) Fallback: toma valores string excepto metadatos
|
||||
return pairs
|
||||
.filter(([k, v]) => !["titulo", "seccion"].includes(String(k).toLowerCase()) && typeof v === "string")
|
||||
.map(([, v]) => String(v))
|
||||
@@ -300,11 +298,8 @@ function Page() {
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
|
||||
{!visible.length && (
|
||||
<div className="text-sm text-neutral-500 py-6 text-center">
|
||||
No hay temas que coincidan.
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 py-6 text-center">No hay temas que coincidan.</div>
|
||||
)}
|
||||
</Accordion>
|
||||
)
|
||||
@@ -314,8 +309,16 @@ function Page() {
|
||||
|
||||
|
||||
{/* Bibliografía */}
|
||||
{a.bibliografia && a.bibliografia.length > 0 && (
|
||||
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
||||
<Section id="bibliografia" title="Bibliografía" icon={Icons.LibraryBig}>
|
||||
<div className="flex justify-end mb-2">
|
||||
<EditBibliografiaButton
|
||||
asignaturaId={a.id}
|
||||
value={a.bibliografia ?? []}
|
||||
onSaved={(bibliografia) => setA(s => ({ ...s, bibliografia }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{a.bibliografia && a.bibliografia.length > 0 ? (
|
||||
<ul className="space-y-2 text-sm text-neutral-800">
|
||||
{a.bibliografia.map((ref, i) => (
|
||||
<li key={i} className="flex items-start gap-2 leading-relaxed">
|
||||
@@ -324,8 +327,11 @@ function Page() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
)}
|
||||
) : (
|
||||
<div className="text-sm text-neutral-500">Sin bibliografía.</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
|
||||
{/* Evaluación */}
|
||||
{a.criterios_evaluacion && (
|
||||
@@ -557,3 +563,213 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function EditContenidosButton({
|
||||
asignaturaId,
|
||||
value,
|
||||
onSaved,
|
||||
}: {
|
||||
asignaturaId: string
|
||||
value: any
|
||||
onSaved: (contenidos: any) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
type UnitDraft = { title: string; temas: string[] }
|
||||
const [units, setUnits] = useState<UnitDraft[]>([])
|
||||
|
||||
// Normaliza el JSON (acepta estructuras flexibles)
|
||||
function normalize(v: any): UnitDraft[] {
|
||||
try {
|
||||
const entries = Object.entries(v ?? {})
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([key, val]) => {
|
||||
const obj = val as any
|
||||
// soporta: { titulo, subtemas:{ "1":"t1" } } | { "1":"t1" } | ["t1","t2"]
|
||||
const title = (typeof obj?.titulo === 'string' && obj.titulo.trim()) ? obj.titulo.trim() : `Unidad ${key}`
|
||||
let temas: string[] = []
|
||||
if (Array.isArray(obj?.subtemas)) temas = obj.subtemas.map(String)
|
||||
else if (obj?.subtemas && typeof obj.subtemas === 'object') {
|
||||
temas = Object.entries(obj.subtemas).sort(([a], [b]) => Number(a) - Number(b)).map(([, t]) => String(t))
|
||||
} else if (Array.isArray(obj)) {
|
||||
temas = obj.map(String)
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
const nums = Object.entries(obj).filter(([k, v]) => /^\d+$/.test(String(k)) && (typeof v === "string" || typeof v === "number"))
|
||||
if (nums.length) temas = nums.sort(([a], [b]) => Number(a) - Number(b)).map(([, t]) => String(t))
|
||||
}
|
||||
return { title, temas }
|
||||
})
|
||||
return entries.length ? entries : [{ title: 'Unidad 1', temas: [] }]
|
||||
} catch {
|
||||
return [{ title: 'Unidad 1', temas: [] }]
|
||||
}
|
||||
}
|
||||
|
||||
// Construye un JSON estable para guardar
|
||||
function buildPayload(us: UnitDraft[]) {
|
||||
const out: any = {}
|
||||
us.forEach((u, idx) => {
|
||||
const k = String(idx + 1)
|
||||
const sub: any = {}
|
||||
u.temas.filter(t => t.trim()).forEach((t, i) => { sub[String(i + 1)] = t.trim() })
|
||||
out[k] = { titulo: u.title.trim() || `Unidad ${k}`, subtemas: sub }
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
setUnits(normalize(value))
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const contenidos = buildPayload(units)
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.update({ contenidos })
|
||||
.eq('id', asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
setSaving(false)
|
||||
if (error) { alert(error.message || 'No se pudo guardar'); return }
|
||||
onSaved((data as any)?.contenidos ?? contenidos)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={openEditor}>
|
||||
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar contenidos
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar contenidos</DialogTitle>
|
||||
<DialogDescription>
|
||||
Títulos de unidad y temas (un tema por línea). Se guardará en un formato consistente con <code>titulo</code> y <code>subtemas</code>.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 max-h-[60vh] overflow-auto pr-1">
|
||||
{units.map((u, i) => (
|
||||
<div key={i} className="rounded-2xl border p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium text-sm">Unidad {i + 1}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Eliminar unidad"
|
||||
onClick={() => setUnits(prev => prev.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<Icons.Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label>Título</Label>
|
||||
<Input
|
||||
value={u.title}
|
||||
onChange={(e) => setUnits(prev => prev.map((uu, idx) => idx === i ? { ...uu, title: e.target.value } : uu))}
|
||||
placeholder={`Unidad ${i + 1}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Temas (uno por línea)</Label>
|
||||
<Textarea
|
||||
className="min-h-[120px]"
|
||||
value={u.temas.join('\n')}
|
||||
onChange={(e) => {
|
||||
const lines = e.target.value.split('\n')
|
||||
setUnits(prev => prev.map((uu, idx) => idx === i ? { ...uu, temas: lines } : uu))
|
||||
}}
|
||||
placeholder={`Tema 1\nTema 2\n…`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setUnits(prev => [...prev, { title: `Unidad ${prev.length + 1}`, temas: [] }])}
|
||||
>
|
||||
<Icons.Plus className="w-4 h-4 mr-2" /> Agregar unidad
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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('')
|
||||
|
||||
function openEditor() {
|
||||
setText((value ?? []).join('\n'))
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true)
|
||||
const refs = text.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
const { data, error } = await supabase
|
||||
.from('asignaturas')
|
||||
.update({ bibliografia: refs })
|
||||
.eq('id', asignaturaId)
|
||||
.select()
|
||||
.maybeSingle()
|
||||
setSaving(false)
|
||||
if (error) { alert(error.message || 'No se pudo guardar'); return }
|
||||
onSaved((data as any)?.bibliografia ?? refs)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={openEditor}>
|
||||
<Icons.Pencil className="h-4 w-4 mr-2" /> Editar bibliografía
|
||||
</Button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar bibliografía</DialogTitle>
|
||||
<DialogDescription>Escribe una referencia por línea.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
className="min-h-[260px]"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={`Autor, Título, Editorial, Año\nDOI/URL\n…`}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
import { useMemo } from 'react'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
|
||||
type Facultad = { id: string; nombre: string; icon: string; color?: string | null }
|
||||
type Plan = {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
||||
import * as Icons from 'lucide-react'
|
||||
import { supabase } from '@/auth/supabase'
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type Facultad = {
|
||||
id: string
|
||||
@@ -17,7 +23,6 @@ export const Route = createFileRoute('/_authenticated/facultades')({
|
||||
.from('facultades')
|
||||
.select('id, nombre, icon, color')
|
||||
.order('nombre')
|
||||
|
||||
if (error) {
|
||||
console.error(error)
|
||||
return { facultades: [] as Facultad[] }
|
||||
@@ -26,55 +31,267 @@ export const Route = createFileRoute('/_authenticated/facultades')({
|
||||
},
|
||||
})
|
||||
|
||||
/* ----------- Paleta curada ----------- */
|
||||
const PALETTE: { name: string; hex: `#${string}` }[] = [
|
||||
{ name: 'Indigo', hex: '#4F46E5' },
|
||||
{ name: 'Blue', hex: '#2563EB' },
|
||||
{ name: 'Sky', hex: '#0EA5E9' },
|
||||
{ name: 'Teal', hex: '#14B8A6' },
|
||||
{ name: 'Emerald', hex: '#10B981' },
|
||||
{ name: 'Lime', hex: '#84CC16' },
|
||||
{ name: 'Amber', hex: '#F59E0B' },
|
||||
{ name: 'Orange', hex: '#F97316' },
|
||||
{ name: 'Red', hex: '#EF4444' },
|
||||
{ name: 'Rose', hex: '#F43F5E' },
|
||||
{ name: 'Violet', hex: '#7C3AED' },
|
||||
{ name: 'Purple', hex: '#9333EA' },
|
||||
{ name: 'Fuchsia', hex: '#C026D3' },
|
||||
{ name: 'Slate', hex: '#334155' },
|
||||
{ name: 'Zinc', hex: '#3F3F46' },
|
||||
{ name: 'Neutral', hex: '#404040' },
|
||||
]
|
||||
|
||||
/* Un set corto y útil de íconos Lucide */
|
||||
const ICON_CHOICES = [
|
||||
'Building2', 'Building', 'School', 'University', 'Landmark', 'Library', 'Layers',
|
||||
'Atom', 'FlaskConical', 'Microscope', 'Cpu', 'Hammer', 'Palette', 'Shapes', 'BookOpen', 'GraduationCap'
|
||||
] as const
|
||||
type IconName = typeof ICON_CHOICES[number]
|
||||
|
||||
function gradientFrom(color?: string | null) {
|
||||
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb' // azul por defecto
|
||||
// degradado elegante con transparencia
|
||||
const base = (color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(color)) ? color : '#2563eb'
|
||||
return `linear-gradient(135deg, ${base} 0%, ${base}CC 40%, ${base}99 70%, ${base}66 100%)`
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { facultades } = Route.useLoaderData() as { facultades: Facultad[] }
|
||||
const router = useRouter()
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [form, setForm] = useState<{ nombre: string; icon: IconName; color: `#${string}` }>(
|
||||
{ nombre: '', icon: 'Building2', color: '#2563EB' }
|
||||
)
|
||||
const [editing, setEditing] = useState<Facultad | null>(null)
|
||||
|
||||
function openCreate() {
|
||||
setForm({ nombre: '', icon: 'Building2', color: '#2563EB' })
|
||||
setCreateOpen(true)
|
||||
}
|
||||
function openEdit(f: Facultad) {
|
||||
setEditing(f)
|
||||
setForm({
|
||||
nombre: f.nombre,
|
||||
icon: (ICON_CHOICES.includes(f.icon as IconName) ? f.icon : 'Building2') as IconName,
|
||||
color: ((f.color && /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.test(f.color)) ? (f.color as `#${string}`) : '#2563EB')
|
||||
})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
|
||||
setSaving(true)
|
||||
const { error } = await supabase.from('facultades')
|
||||
.insert({ nombre: form.nombre.trim(), icon: form.icon, color: form.color })
|
||||
setSaving(false)
|
||||
if (error) { console.error(error); toast.error('No se pudo crear'); return }
|
||||
toast.success('Facultad creada ✨')
|
||||
setCreateOpen(false)
|
||||
router.invalidate()
|
||||
}
|
||||
|
||||
async function doEdit() {
|
||||
if (!editing) return
|
||||
if (!form.nombre.trim()) { toast.error('Nombre requerido'); return }
|
||||
setSaving(true)
|
||||
const { error } = await supabase.from('facultades')
|
||||
.update({ nombre: form.nombre.trim(), icon: form.icon, color: form.color })
|
||||
.eq('id', editing.id)
|
||||
setSaving(false)
|
||||
if (error) { console.error(error); toast.error('No se pudo guardar'); return }
|
||||
toast.success('Cambios guardados ✅')
|
||||
setEditOpen(false)
|
||||
setEditing(null)
|
||||
router.invalidate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold flex items-center gap-2">
|
||||
<Icons.Building2 className="w-5 h-5" /> Facultades
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.invalidate()}>
|
||||
<Icons.RefreshCcw className="w-4 h-4 mr-2" /> Recargar
|
||||
</Button>
|
||||
<Button onClick={openCreate}>
|
||||
<Icons.Plus className="w-4 h-4 mr-2" /> Nueva facultad
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{facultades.map((fac) => {
|
||||
const LucideIcon = (Icons as any)[fac.icon] || Icons.Building
|
||||
const bg = useMemo(() => ({ background: gradientFrom(fac.color) }), [fac.color])
|
||||
const LucideIcon = (Icons as any)[fac.icon] || Icons.Building2
|
||||
const bg = { background: gradientFrom(fac.color) } // ← sin useMemo aquí
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={fac.id}
|
||||
to="/facultad/$facultadId"
|
||||
params={{ facultadId: fac.id }}
|
||||
aria-label={`Administrar ${fac.nombre}`}
|
||||
className="group relative block rounded-3xl overflow-hidden shadow-xl focus:outline-none focus-visible:ring-4 ring-white/60"
|
||||
style={bg}
|
||||
>
|
||||
{/* capa brillo */}
|
||||
<div className="absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity" style={{
|
||||
background: 'radial-gradient(1200px 400px at 20% -20%, rgba(255,255,255,.45), transparent 60%)'
|
||||
}} />
|
||||
|
||||
{/* contenido */}
|
||||
<div className="relative h-56 sm:h-64 lg:h-72 p-6 flex flex-col justify-between text-white">
|
||||
<LucideIcon className="w-20 h-20 md:w-24 md:h-24 drop-shadow-md" />
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="text-xl md:text-2xl font-bold drop-shadow-sm pr-2">
|
||||
{fac.nombre}
|
||||
</h3>
|
||||
<Icons.ArrowRight className="w-6 h-6 opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
|
||||
<div key={fac.id} className="group relative rounded-3xl overflow-hidden shadow-xl border">
|
||||
<Link
|
||||
to="/facultad/$facultadId"
|
||||
params={{ facultadId: fac.id }}
|
||||
aria-label={`Administrar ${fac.nombre}`}
|
||||
className="block focus:outline-none focus-visible:ring-4 ring-white/60"
|
||||
style={bg}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-0 group-hover:opacity-15 transition-opacity"
|
||||
style={{ background: 'radial-gradient(1200px 400px at 20% -20%, rgba(255,255,255,.45), transparent 60%)' }}
|
||||
/>
|
||||
<div className="relative h-56 sm:h-64 lg:h-72 p-6 flex flex-col justify-between text-white">
|
||||
<LucideIcon className="w-20 h-20 md:w-24 md:h-24 drop-shadow-md" />
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="text-xl md:text-2xl font-bold drop-shadow-sm pr-2">{fac.nombre}</h3>
|
||||
<Icons.ArrowRight className="w-6 h-6 opacity-0 translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* borde dinámico al hover */}
|
||||
<div className="absolute inset-0 ring-0 group-hover:ring-4 group-active:ring-4 ring-white/40 transition-[ring-width]" />
|
||||
{/* animación sutil */}
|
||||
<div className="absolute inset-0 scale-100 group-hover:scale-[1.02] group-active:scale-[0.99] transition-transform duration-300" />
|
||||
</Link>
|
||||
<div className="absolute top-3 right-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-white/80 hover:bg-white"
|
||||
onClick={() => openEdit(fac)}
|
||||
>
|
||||
<Icons.Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Dialog Crear */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nueva facultad</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<FormFields form={form} setForm={setForm} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancelar</Button>
|
||||
<Button onClick={doCreate} disabled={saving}>{saving ? 'Guardando…' : 'Crear'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog Editar */}
|
||||
<Dialog open={editOpen} onOpenChange={(o) => { setEditOpen(o); if (!o) setEditing(null) }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar facultad</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<FormFields form={form} setForm={setForm} />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setEditOpen(false); setEditing(null) }}>Cancelar</Button>
|
||||
<Button onClick={doEdit} disabled={saving}>{saving ? 'Guardando…' : 'Guardar'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ----------- Subcomponentes ----------- */
|
||||
function FormFields({
|
||||
form, setForm
|
||||
}: {
|
||||
form: { nombre: string; icon: IconName; color: `#${string}` }
|
||||
setForm: React.Dispatch<React.SetStateAction<{ nombre: string; icon: IconName; color: `#${string}` }>>
|
||||
}) {
|
||||
const PreviewIcon = (Icons as any)[form.icon] || Icons.Building2
|
||||
const bg = useMemo(() => ({ background: gradientFrom(form.color) }), [form.color])
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{/* Preview */}
|
||||
<div className="rounded-2xl overflow-hidden border">
|
||||
<div className="h-36 p-4 flex items-end justify-between text-white" style={bg}>
|
||||
<PreviewIcon className="w-14 h-14 drop-shadow-md" />
|
||||
<span className="text-xs bg-white/20 px-2 py-1 rounded">Vista previa</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nombre */}
|
||||
<div className="space-y-1">
|
||||
<Label>Nombre</Label>
|
||||
<Input
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm(s => ({ ...s, nombre: e.target.value }))}
|
||||
placeholder="Facultad de Ingeniería"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Icono */}
|
||||
<div className="space-y-1">
|
||||
<Label>Ícono</Label>
|
||||
<Select value={form.icon} onValueChange={(v) => setForm(s => ({ ...s, icon: v as IconName }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Selecciona ícono" /></SelectTrigger>
|
||||
<SelectContent className="max-h-72">
|
||||
{ICON_CHOICES.map(k => {
|
||||
const Ico = (Icons as any)[k]
|
||||
return (
|
||||
<SelectItem key={k} value={k}>
|
||||
<span className="inline-flex items-center gap-2"><Ico className="w-4 h-4" /> {k}</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Color (paleta curada) */}
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<ColorGrid
|
||||
value={form.color}
|
||||
onChange={(hex) => setForm(s => ({ ...s, color: hex }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorGrid({ value, onChange }: { value: `#${string}`; onChange: (hex: `#${string}`) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{PALETTE.map(c => (
|
||||
<button
|
||||
key={c.hex}
|
||||
type="button"
|
||||
onClick={() => onChange(c.hex)}
|
||||
className={`relative h-9 rounded-xl ring-1 ring-black/10 transition
|
||||
${value === c.hex ? 'outline outline-2 outline-offset-2 outline-black/70' : 'hover:scale-[1.03]'}`}
|
||||
style={{ background: c.hex }}
|
||||
title={c.name}
|
||||
aria-label={c.name}
|
||||
>
|
||||
{value === c.hex && (
|
||||
<Icons.Check className="absolute right-1.5 bottom-1.5 w-4 h-4 text-white drop-shadow" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -250,6 +250,7 @@ function RouteComponent() {
|
||||
<div className="academics">
|
||||
<AcademicSections planId={plan.id} plan={plan} color={fac?.color} />
|
||||
</div>
|
||||
{/* ===== Asignaturas (preview cards) ===== */}
|
||||
<Card className="border shadow-sm">
|
||||
<CardHeader className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">Asignaturas ({asignaturasCount})</CardTitle>
|
||||
@@ -258,32 +259,29 @@ function RouteComponent() {
|
||||
<AddAsignaturaButton planId={plan.id} onAdded={() => router.invalidate()} />
|
||||
<Link
|
||||
to="/asignaturas/$planId"
|
||||
search={{ q: "", planId: plan.id, carreraId: '', f: '', facultadId: '' }}
|
||||
search={{ q: "", planId: plan.id, carreraId: "", f: "", facultadId: "" }}
|
||||
params={{ planId: plan.id }}
|
||||
className="inline-flex items-center gap-2 rounded-xl border px-3 py-2 text-sm hover:bg-neutral-50"
|
||||
title="Ver todas las asignaturas"
|
||||
>
|
||||
<Icons.BookOpen className="w-4 h-4" /> Ver todas
|
||||
<Icons.BookOpen className="w-4 h-4" /> Ver en página de Asignaturas
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{asignaturasPreview.length === 0 && (
|
||||
<CardContent>
|
||||
{asignaturasPreview.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500">Sin asignaturas</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{asignaturasPreview.map((a) => (
|
||||
<AsignaturaPreviewCard key={a.id} asignatura={a} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{asignaturasPreview.map(a => (
|
||||
<Link
|
||||
to="/asignatura/$asignaturaId"
|
||||
params={{ asignaturaId: a.id }}
|
||||
className="rounded-full border px-3 py-1 text-xs bg-white/70 hover:bg-white transition"
|
||||
title={a.nombre}
|
||||
>
|
||||
{a.semestre ? `S${a.semestre} · ` : ''}{a.nombre}
|
||||
</Link>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -404,9 +402,9 @@ function AdjustAIButton({ plan }: { plan: PlanFull }) {
|
||||
|
||||
async function apply() {
|
||||
setLoading(true)
|
||||
await fetch('https://genesis-engine.apps.lci.ulsa.mx/ajustar/plan', {
|
||||
await fetch('https://genesis-engine.apps.lci.ulsa.mx/api/mejorar/plan', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, plan }),
|
||||
body: JSON.stringify({ prompt, plan_id: plan.id }),
|
||||
}).catch(() => { })
|
||||
setLoading(false)
|
||||
setOpen(false)
|
||||
@@ -629,3 +627,146 @@ function AddAsignaturaButton({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function AsignaturaPreviewCard({
|
||||
asignatura,
|
||||
}: {
|
||||
asignatura: { id: string; nombre: string; semestre: number | null; creditos: number | null }
|
||||
}) {
|
||||
const [extra, setExtra] = useState<{
|
||||
tipo: string | null
|
||||
horas_teoricas: number | null
|
||||
horas_practicas: number | null
|
||||
contenidos: Record<string, Record<string, string>> | null
|
||||
} | null>(null)
|
||||
|
||||
// Carga perezosa de info extra para enriquecer la tarjeta (8 items máx: ok)
|
||||
useEffect(() => {
|
||||
let ignore = false
|
||||
; (async () => {
|
||||
const { data } = await supabase
|
||||
.from("asignaturas")
|
||||
.select("tipo, horas_teoricas, horas_practicas, contenidos")
|
||||
.eq("id", asignatura.id)
|
||||
.maybeSingle()
|
||||
if (!ignore) setExtra((data as any) ?? null)
|
||||
})()
|
||||
return () => {
|
||||
ignore = true
|
||||
}
|
||||
}, [asignatura.id])
|
||||
|
||||
const horasT = extra?.horas_teoricas ?? null
|
||||
const horasP = extra?.horas_practicas ?? null
|
||||
const horasTot = (horasT ?? 0) + (horasP ?? 0)
|
||||
|
||||
// Conteo rápido de unidades/temas si existen
|
||||
const resumenContenidos = useMemo(() => {
|
||||
const c = extra?.contenidos
|
||||
if (!c) return { unidades: 0, temas: 0 }
|
||||
const unidades = Object.keys(c).length
|
||||
const temas = Object.values(c).reduce((acc, temasObj) => acc + Object.keys(temasObj || {}).length, 0)
|
||||
return { unidades, temas }
|
||||
}, [extra?.contenidos])
|
||||
|
||||
// estilo por tipo
|
||||
const tipo = (extra?.tipo ?? "").toLowerCase()
|
||||
const tipoChip =
|
||||
tipo.includes("oblig")
|
||||
? "bg-emerald-50 text-emerald-700 border-emerald-200"
|
||||
: tipo.includes("opt")
|
||||
? "bg-amber-50 text-amber-800 border-amber-200"
|
||||
: tipo.includes("taller")
|
||||
? "bg-indigo-50 text-indigo-700 border-indigo-200"
|
||||
: tipo.includes("lab")
|
||||
? "bg-sky-50 text-sky-700 border-sky-200"
|
||||
: "bg-neutral-100 text-neutral-700 border-neutral-200"
|
||||
|
||||
return (
|
||||
<article
|
||||
className="group relative overflow-hidden rounded-2xl border bg-white/70 dark:bg-neutral-900/60 backdrop-blur p-4 shadow-sm hover:shadow-md transition-all hover:-translate-y-0.5"
|
||||
role="region"
|
||||
aria-label={asignatura.nombre}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-9 w-9 rounded-xl grid place-items-center border bg-white/80">
|
||||
<Icons.BookOpen className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate" title={asignatura.nombre}>
|
||||
{asignatura.nombre}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{asignatura.semestre != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||
<Icons.Calendar className="h-3 w-3" /> S{asignatura.semestre}
|
||||
</span>
|
||||
)}
|
||||
{asignatura.creditos != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5">
|
||||
<Icons.Coins className="h-3 w-3" /> {asignatura.creditos} cr
|
||||
</span>
|
||||
)}
|
||||
{extra?.tipo && (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 ${tipoChip}`}>
|
||||
<Icons.Tag className="h-3 w-3" /> {extra.tipo}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* cuerpo */}
|
||||
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
|
||||
<SmallStat icon={Icons.Clock} label="Horas" value={horasTot || "—"} />
|
||||
<SmallStat icon={Icons.BookMarked} label="Unidades" value={resumenContenidos.unidades || "—"} />
|
||||
<SmallStat icon={Icons.ListTree} label="Temas" value={resumenContenidos.temas || "—"} />
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<div className="text-[11px] text-neutral-500">
|
||||
{horasT != null || horasP != null ? (
|
||||
<>H T/P: {horasT ?? "—"}/{horasP ?? "—"}</>
|
||||
) : (
|
||||
<span className="opacity-70">Resumen listo</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/asignatura/$asignaturaId"
|
||||
params={{ asignaturaId: asignatura.id }}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs hover:bg-neutral-50"
|
||||
title="Ver detalle"
|
||||
>
|
||||
Ver detalle <Icons.ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* glow sutil en hover */}
|
||||
<div className="pointer-events-none absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ background: "radial-gradient(600px 120px at 20% -10%, rgba(0,0,0,.06), transparent 60%)" }} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function SmallStat({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
||||
label: string
|
||||
value: string | number
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-white/60 dark:bg-neutral-900/50 px-2.5 py-2">
|
||||
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
|
||||
<Icon className="h-3.5 w-3.5" /> {label}
|
||||
</div>
|
||||
<div className="mt-0.5 font-medium tabular-nums">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user