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:
2025-08-26 15:24:53 -06:00
parent 602c5dbb31
commit 56b0dc8a62
12 changed files with 2240 additions and 1188 deletions

View File

@@ -11,7 +11,7 @@
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Create TanStack App - tanstack-router</title>
<title>Genesis - La Salle</title>
</head>
<body>
<div id="app"></div>

View File

@@ -1,6 +1,6 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"short_name": "Genesis",
"name": "Genesis - La Salle",
"icons": [
{
"src": "favicon.ico",

View File

@@ -1,109 +1,147 @@
import { createClient, type User } from '@supabase/supabase-js'
import { createClient, type User, type Session } from '@supabase/supabase-js'
import { createContext, useContext, useEffect, useState } from 'react'
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY,
)
export interface SupabaseAuthState {
isAuthenticated: boolean
user: User | null
claims: UserClaims | null
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
isLoading: boolean
isAuthenticated: boolean
user: User | null
claims: UserClaims | null
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
isLoading: boolean
}
type Role =
| 'lci'
| 'vicerrectoria'
| 'director_facultad' // 👈 NEW
| 'secretario_academico'
| 'jefe_carrera'
| 'planeacion'
type UserClaims = {
claims_admin: boolean,
clave: string,
nombre: string,
apellidos: string,
title: string,
avatar: string | null,
carrera_id?: string,
facultad_id?: string,
role: 'lci' | 'vicerrectoria' | 'secretario_academico' | 'jefe_carrera' | 'planeacion',
claims_admin: boolean
clave: string
nombre: string
apellidos: string
title: string
avatar: string | null
carrera_id?: string | null
facultad_id?: string | null
facultad_color?: string | null // 🎨 NEW
role: Role
}
const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(
undefined,
)
const SupabaseAuthContext = createContext<SupabaseAuthState | undefined>(undefined)
export function SupabaseAuthProvider({
children,
}: {
children: React.ReactNode
}) {
const [user, setUser] = useState<User | null>(null)
const [claims, setClaims] = useState<UserClaims | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
export function SupabaseAuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [claims, setClaims] = useState<UserClaims | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null)
setClaims({
...(session?.user?.app_metadata as Partial<UserClaims> ?? {}),
...(session?.user?.user_metadata as Partial<UserClaims> ?? {}),
} as UserClaims | null)
setIsAuthenticated(!!session?.user)
setIsLoading(false)
})
useEffect(() => {
// Carga inicial
supabase.auth.getSession().then(async ({ data: { session } }) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
})
// Listen for auth changes
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null)
setClaims({
...(session?.user?.app_metadata as Partial<UserClaims> ?? {}),
...(session?.user?.user_metadata as Partial<UserClaims> ?? {}),
} as UserClaims | null)
setIsAuthenticated(!!session?.user)
setIsLoading(false)
})
// Suscripción a cambios de sesión
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (_event, session) => {
const u = session?.user ?? null
setUser(u)
setIsAuthenticated(!!u)
setClaims(await buildClaims(session))
setIsLoading(false)
})
return () => subscription.unsubscribe()
}, [])
return () => subscription.unsubscribe()
}, [])
const login = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error?.code === 'invalid_credentials') throw new Error('Credenciales inválidas')
else if (error) throw error
}
const login = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error?.code === 'invalid_credentials') throw new Error('Credenciales inválidas')
else if (error) throw error
}
const logout = async () => {
const { error } = await supabase.auth.signOut()
if (error) throw error
location.href = "/login"
}
const logout = async () => {
const { error } = await supabase.auth.signOut()
if (error) throw error
location.href = "/login"
}
return (
<SupabaseAuthContext.Provider
value={{
isAuthenticated,
user,
claims,
login,
logout,
isLoading,
}}
>
{children}
</SupabaseAuthContext.Provider>
)
return (
<SupabaseAuthContext.Provider
value={{ isAuthenticated, user, claims, login, logout, isLoading }}
>
{children}
</SupabaseAuthContext.Provider>
)
}
export function useSupabaseAuth() {
const context = useContext(SupabaseAuthContext)
if (context === undefined) {
throw new Error('useSupabaseAuth must be used within SupabaseAuthProvider')
}
return context
const context = useContext(SupabaseAuthContext)
if (context === undefined) {
throw new Error('useSupabaseAuth must be used within SupabaseAuthProvider')
}
return context
}
/* ===================== *
* Helpers
* ===================== */
// Unifica extracción de metadatos y resuelve facultad_color si hay facultad_id
async function buildClaims(session: Session | null): Promise<UserClaims | null> {
const u = session?.user
if (!u) return null
const app = (u.app_metadata ?? {}) as Partial<UserClaims> & { role?: Role }
const meta = (u.user_metadata ?? {}) as Partial<UserClaims>
// Mezcla segura: app_metadata > user_metadata (para campos de claims)
const base: Partial<UserClaims> = {
claims_admin: !!(app.claims_admin ?? (meta as any).claims_admin),
role: (app.role as Role | undefined) ?? ('lci' as Role),
facultad_id: app.facultad_id ?? meta.facultad_id ?? null,
carrera_id: app.carrera_id ?? meta.carrera_id ?? null,
clave: (meta.clave as string) ?? '',
nombre: (meta.nombre as string) ?? '',
apellidos: (meta.apellidos as string) ?? '',
title: (meta.title as string) ?? '',
avatar: (meta.avatar as string) ?? null,
}
let facultad_color: string | null = null
if (base.facultad_id) {
// Lee color desde public.facultades
const { data, error } = await supabase
.from('facultades')
.select('color')
.eq('id', base.facultad_id)
.maybeSingle()
if (!error && data) facultad_color = (data as any)?.color ?? null
}
return {
claims_admin: !!base.claims_admin,
role: (base.role ?? 'lci') as Role,
clave: base.clave ?? '',
nombre: base.nombre ?? '',
apellidos: base.apellidos ?? '',
title: base.title ?? '',
avatar: base.avatar ?? null,
facultad_id: (base.facultad_id as string | null) ?? null,
carrera_id: (base.carrera_id as string | null) ?? null,
facultad_color, // 🎨
}
}

View File

@@ -1,82 +1,109 @@
import * as Icons from "lucide-react"
import { useMemo, useState } from "react"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { supabase } from "@/auth/supabase"
/* color helpers */
/* ---------- helpers de color ---------- */
function hexToRgb(hex?: string | null): [number, number, number] {
if (!hex) return [37, 99, 235]
const h = hex.replace("#", ""); const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
const n = parseInt(v, 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
const h = hex.replace("#", "")
const v = h.length === 3 ? h.split("").map(c => c + c).join("") : h
const n = parseInt(v, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
const rgba = (rgb: [number, number, number], a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`
/* texto con clamp */
function ExpandableText({ text, mono = false }: { text?: string | null; mono?: boolean }) {
/* ---------- texto expandible (acepta string o string[]) ---------- */
function ExpandableText({ text, mono = false }: { text?: string | string[] | null; mono?: boolean }) {
const [open, setOpen] = useState(false)
if (!text) return <span className="text-neutral-400"></span>
if (!text || (Array.isArray(text) && text.length === 0)) {
return <span className="text-neutral-400"></span>
}
const content = Array.isArray(text) ? text.join("\n• ") : text
const rendered = Array.isArray(text) ? `${content}` : content
return (
<div>
<div className={`${mono ? 'font-mono whitespace-pre-wrap' : ''} text-sm ${open ? '' : 'line-clamp-10'}`}>{text}</div>
{text.length > 220 && (
<div className={`${mono ? "font-mono whitespace-pre-wrap" : ""} text-sm ${open ? "" : "line-clamp-10"}`}>
{rendered}
</div>
{String(rendered).length > 220 && (
<button onClick={() => setOpen(v => !v)} className="mt-2 text-xs font-medium text-neutral-600 hover:underline">
{open ? 'Ver menos' : 'Ver más'}
{open ? "Ver menos" : "Ver más"}
</button>
)}
</div>
)
}
/* panel con estilo */
/* ---------- panel con aurora mesh ---------- */
function SectionPanel({
title, icon: Icon, color, children,
}: { title: string; icon: any; color?: string | null; children: React.ReactNode }) {
title, icon: Icon, color, children, id,
}: { title: string; icon: any; color?: string | null; children: React.ReactNode; id: string }) {
const rgb = hexToRgb(color)
return (
<div className="rounded-3xl border backdrop-blur shadow-sm overflow-hidden">
<div className="px-4 py-3 flex items-center gap-2"
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}>
<span className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2"
style={{ borderColor: rgba(rgb, .25), background: "rgba(255,255,255,.75)" }}>
<section id={id} className="relative rounded-3xl border overflow-hidden shadow-sm bg-white/70 dark:bg-neutral-900/60">
{/* aurora mesh sutil */}
<div className="pointer-events-none absolute inset-0 -z-0">
<div
className="absolute -top-20 -left-20 w-[28rem] h-[28rem] rounded-full blur-3xl"
style={{ background: `radial-gradient(circle, ${rgba(rgb, .20)}, transparent 60%)` }}
/>
<div
className="absolute -bottom-24 -right-16 w-[24rem] h-[24rem] rounded-full blur-[60px]"
style={{ background: `radial-gradient(circle, ${rgba(rgb, .14)}, transparent 60%)` }}
/>
</div>
<div
className="relative z-10 px-4 py-3 flex items-center gap-2 border-b"
style={{ background: `linear-gradient(180deg, ${rgba(rgb, .10)}, transparent)` }}
>
<span
className="inline-flex items-center justify-center rounded-xl border px-2.5 py-2 bg-white/80"
style={{ borderColor: rgba(rgb, .25) }}
>
<Icon className="w-4 h-4" />
</span>
<h3 className="font-semibold">{title}</h3>
</div>
<div className="p-5">{children}</div>
</div>
<div className="relative z-10 p-5">{children}</div>
</section>
)
}
/* ---------- TABS + EDIT DIALOG ---------- */
/* ---------- Secciones integradas (sin tabs) ---------- */
type PlanTextFields = {
objetivo_general?: string | null; sistema_evaluacion?: string | null;
perfil_ingreso?: string | null; perfil_egreso?: string | null;
competencias_genericas?: string | null; competencias_especificas?: string | null;
indicadores_desempeno?: string | null; pertinencia?: string | null; prompt?: string | null;
objetivo_general?: string | string[] | null
sistema_evaluacion?: string | string[] | null
perfil_ingreso?: string | string[] | null
perfil_egreso?: string | string[] | null
competencias_genericas?: string | string[] | null
competencias_especificas?: string | string[] | null
indicadores_desempeno?: string | string[] | null
pertinencia?: string | string[] | null
prompt?: string | null
}
export function AcademicSections({
planId, plan, color,
}: { planId: string; plan: PlanTextFields; color?: string | null }) {
// estado local editable
const [local, setLocal] = useState<PlanTextFields>({ ...plan })
const [editing, setEditing] = useState<null | { key: keyof PlanTextFields; title: string }>(null)
const [draft, setDraft] = useState("")
const [saving, setSaving] = useState(false)
const sections = useMemo(() => [
{ id: "obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
{ id: "eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
{ id: "ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
{ id: "egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
{ id: "cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
{ id: "ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
{ id: "ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
{ id: "pert", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
{ id: "prompt", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
{ id: "sec-obj", title: "Objetivo general", icon: Icons.Target, key: "objetivo_general" as const, mono: false },
{ id: "sec-eval", title: "Sistema de evaluación", icon: Icons.CheckCircle2, key: "sistema_evaluacion" as const, mono: false },
{ id: "sec-ing", title: "Perfil de ingreso", icon: Icons.UserPlus, key: "perfil_ingreso" as const, mono: false },
{ id: "sec-egr", title: "Perfil de egreso", icon: Icons.GraduationCap, key: "perfil_egreso" as const, mono: false },
{ id: "sec-cg", title: "Competencias genéricas", icon: Icons.Sparkles, key: "competencias_genericas" as const, mono: false },
{ id: "sec-ce", title: "Competencias específicas", icon: Icons.Cog, key: "competencias_especificas" as const, mono: false },
{ id: "sec-ind", title: "Indicadores de desempeño", icon: Icons.LineChart, key: "indicadores_desempeno" as const, mono: false },
{ id: "sec-per", title: "Pertinencia", icon: Icons.Lightbulb, key: "pertinencia" as const, mono: false },
{ id: "sec-prm", title: "Prompt (origen)", icon: Icons.Code2, key: "prompt" as const, mono: true },
], [])
async function handleSave() {
@@ -96,50 +123,55 @@ export function AcademicSections({
return (
<>
<Tabs defaultValue={sections[0].id} className="w-full">
{/* nav sticky con píldoras scrollables */}
<div className="sticky top-0 z-10 backdrop-blur border-b">
<TabsList className="w-full flex gap-2 p-3 scrollbar-none">
{sections.map(s => (
<TabsTrigger key={s.id} value={s.id}
className="data-[state=active]:bg-primary data-[state=active]:text-white p-3 text-xs">
{s.title}
</TabsTrigger>
))}
</TabsList>
</div>
{/* contenido */}
{/* Todas las tarjetas visibles */}
<div className="grid gap-5 md:grid-cols-2">
{sections.map(s => {
const text = local[s.key] ?? null
return (
<TabsContent key={s.id} value={s.id} className="mt-4">
<SectionPanel title={s.title} icon={s.icon} color={color}>
<ExpandableText text={text} mono={s.mono} />
<div className="mt-4 flex gap-2">
<Button variant="outline" size="sm" disabled={!text}
onClick={() => text && navigator.clipboard.writeText(text)}>Copiar</Button>
<Button variant="ghost" size="sm"
onClick={() => { setEditing({ key: s.key, title: s.title }); setDraft(text ?? "") }}>
Editar
</Button>
</div>
</SectionPanel>
</TabsContent>
<SectionPanel key={s.id} id={s.id} title={s.title} icon={s.icon} color={color}>
<ExpandableText text={text} mono={s.mono} />
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
disabled={!text || (Array.isArray(text) && text.length === 0)}
onClick={() => {
const toCopy = Array.isArray(text) ? text.join("\n") : (text ?? "")
if (toCopy) navigator.clipboard.writeText(toCopy)
}}
>
Copiar
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
const current = Array.isArray(text) ? text.join("\n") : (text ?? "")
setEditing({ key: s.key, title: s.title })
setDraft(current)
}}
>
Editar
</Button>
</div>
</SectionPanel>
)
})}
</Tabs>
</div>
{/* Dialog de edición */}
{/* Diálogo de edición */}
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}</DialogTitle>
<DialogTitle>
{editing ? `Editar: ${sections.find(x => x.key === editing.key)?.title}` : ""}
</DialogTitle>
</DialogHeader>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className={`min-h-[260px] ${editing?.key === 'prompt' ? 'font-mono' : ''}`}
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
placeholder="Escribe aquí…"
/>
<DialogFooter>

View File

@@ -14,7 +14,7 @@ export const Route = createRootRouteWithContext<AuthContext>()({
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
<Outlet />
<TanstackDevtools
{/* <TanstackDevtools
config={{
position: 'bottom-left',
}}
@@ -24,7 +24,7 @@ export const Route = createRootRouteWithContext<AuthContext>()({
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
/> */}
</ThemeProvider>
</>
),

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -1,6 +1,5 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { ArrowRight } from "lucide-react"
import '../App.css'
@@ -10,12 +9,12 @@ export const Route = createFileRoute('/')({
function App() {
return (
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-950 via-slate-900 to-slate-800 text-white">
<div className="min-h-screen flex flex-col bg-gradient-to-br from-slate-150 via-slate-200 to-primary">
{/* Navbar */}
<header className="flex items-center justify-between px-10 py-6 border-b border-slate-700/50">
<h1 className="text-2xl font-mono tracking-tight">Génesis</h1>
<Link to="/login" search={{ redirect: '/planes' }}>
<Button variant="outline" className="text-white border-slate-500 hover:bg-slate-700/50">
<Button variant="outline" className="border-slate-500 hover:bg-slate-700/50">
Iniciar sesión
</Button>
</Link>
@@ -24,10 +23,10 @@ function App() {
{/* Hero */}
<main className="flex-1 flex flex-col items-center justify-center text-center px-6">
<h2 className="text-5xl md:text-6xl font-mono font-bold mb-6">
Bienvenido a <span className="text-cyan-400">Génesis</span>
Bienvenido a <span className="text-primary">Génesis</span>
</h2>
<p className="text-lg md:text-xl text-slate-300 max-w-2xl mb-8">
El sistema académico diseñado para transformar la gestión universitaria 🚀.
<p className="text-lg md:text-xl max-w-2xl mb-8">
El sistema académico diseñado para transformar la gestión universitaria.
Seguro, moderno y hecho para crecer contigo.
</p>
@@ -37,30 +36,14 @@ function App() {
Comenzar <ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Button size="lg" variant="outline" className="rounded-2xl px-6 py-3 text-lg font-mono border-slate-600 text-slate-200 hover:bg-slate-700/50">
<Button size="lg" variant="outline" className="rounded-2xl px-6 py-3 text-lg font-mono border-slate-600 hover:bg-slate-100/50">
Conoce más
</Button>
</div>
</main>
{/* Highlights */}
<section className="grid md:grid-cols-3 gap-6 px-10 py-16 bg-slate-900/60 backdrop-blur-md border-t border-slate-700/50">
{[
{ title: "Gestión Académica", desc: "Administra planes de estudio, carreras y facultades con total control." },
{ title: "Tecnología Moderna", desc: "Construido con React, Supabase y prácticas seguras de última generación." },
{ title: "Escalable", desc: "Diseñado para crecer junto con tu institución." }
].map((item, i) => (
<Card key={i} className="bg-slate-800/60 border-slate-700 hover:border-cyan-500 transition-colors duration-300">
<CardContent className="p-6">
<h3 className="font-mono text-xl mb-3 text-cyan-400">{item.title}</h3>
<p className="text-slate-300">{item.desc}</p>
</CardContent>
</Card>
))}
</section>
{/* Footer */}
<footer className="py-6 text-center text-slate-400 text-sm border-t border-slate-700/50">
<footer className="py-6 text-center text-sm border-t border-slate-700/50">
© {new Date().getFullYear()} Génesis Universidad La Salle
</footer>
</div>