57b07b9b82
Rediseño hecho en sesión aparte con el skill impeccable. Se registran las especificaciones y ajustes visuales al sistema ya en producción. Añade: - BrandMark.astro con el escudo/logo del laboratorio. - Favicon actualizado (svg + ico). - Páginas propias 403 y 404 consistentes con el sistema visual. - DESIGN.md y PRODUCT.md documentando decisiones. - .impeccable/design.json con el design system tokenizado. Ajusta componentes existentes (modales, autocomplete, layout, login, solicitudes, mis-préstamos, middleware) para alinearse a los tokens del design system. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
205 lines
11 KiB
Plaintext
205 lines
11 KiB
Plaintext
---
|
|
import Layout from './Layout.astro';
|
|
import Toaster from '@/components/Toaster.tsx';
|
|
import BrandMark from '@/components/BrandMark.astro';
|
|
|
|
interface Props {
|
|
title?: string;
|
|
}
|
|
const { title } = Astro.props;
|
|
|
|
const profile = Astro.locals.profile;
|
|
const isAdmin = profile?.rol === 'admin';
|
|
const path = Astro.url.pathname;
|
|
const isActive = (href: string) => path === href || path.startsWith(href + '/');
|
|
|
|
type NavItem = { href: string; label: string; icon: string };
|
|
|
|
// Iconos: paths de heroicons/lucide outline (24x24). Solo el `d` del <path>.
|
|
const ICONS = {
|
|
menu: 'M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5',
|
|
stats: 'M2.25 18L9 11.25l4.306 4.306a11.95 11.95 0 015.814-5.518l2.74-1.22m0 0l-5.94-2.28m5.94 2.28l-2.28 5.941',
|
|
home: 'M2.25 12l8.954-8.955a1.5 1.5 0 012.122 0L22.28 12M4.5 9.75v10.125A1.125 1.125 0 005.625 21H9.75v-6h4.5v6h4.125A1.125 1.125 0 0019.5 19.875V9.75',
|
|
grid: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 8.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z',
|
|
list: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z',
|
|
inbox: 'M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.013 1.244h3.218a2.25 2.25 0 002.013-1.244l.256-.512a2.25 2.25 0 012.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H6.911a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661z',
|
|
box: 'M21 8.25c0-2.485-2.099-4.5-4.688-4.5H7.688C5.098 3.75 3 5.765 3 8.25v7.5c0 2.485 2.099 4.5 4.688 4.5h8.624c2.59 0 4.688-2.015 4.688-4.5v-7.5zM3 8.25l9 5.25 9-5.25',
|
|
chart: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z',
|
|
logout: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75',
|
|
dashboard: 'M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605',
|
|
};
|
|
|
|
const alumnoNav: NavItem[] = [
|
|
{ href: '/', label: 'Inicio', icon: 'home' },
|
|
{ href: '/alumno/catalogo', label: 'Catálogo', icon: 'grid' },
|
|
{ href: '/alumno/mis-prestamos', label: 'Mis préstamos', icon: 'list' },
|
|
];
|
|
const adminNav: NavItem[] = [
|
|
{ href: '/admin', label: 'Panel', icon: 'dashboard' },
|
|
{ href: '/admin/solicitudes', label: 'Solicitudes', icon: 'inbox' },
|
|
{ href: '/admin/inventario', label: 'Estadísticas', icon: 'stats' },
|
|
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
|
|
];
|
|
const nav = isAdmin ? adminNav : alumnoNav;
|
|
|
|
const activeCheck = (item: NavItem) => {
|
|
if (item.href === '/') return path === '/';
|
|
if (item.href === '/admin') return path === '/admin';
|
|
return isActive(item.href);
|
|
};
|
|
---
|
|
<Layout title={title}>
|
|
<!-- No-flash: aplica estado colapsado del sidebar antes de pintar -->
|
|
<script is:inline>
|
|
try {
|
|
if (localStorage.getItem('sidebar') === 'collapsed') {
|
|
document.documentElement.classList.add('sidebar-collapsed');
|
|
}
|
|
} catch (_) {}
|
|
</script>
|
|
|
|
<div class="min-h-screen flex flex-col md:block">
|
|
|
|
<!-- Sidebar desktop -->
|
|
<aside id="app-sidebar" class="hidden md:flex md:fixed md:top-0 md:left-0 md:w-64 md:h-screen bg-[color:var(--color-primary)] text-white md:flex-col md:z-40 sidebar-panel transition-transform duration-200 ease-out">
|
|
<a href="/" class="p-6 flex items-center gap-3 border-b border-white/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" aria-label="LabPréstamos — Inicio">
|
|
<div class="w-9 h-9 rounded-lg bg-white/15 grid place-items-center p-2" aria-hidden="true">
|
|
<BrandMark class="w-full h-full text-white" />
|
|
</div>
|
|
<div>
|
|
<div class="font-semibold leading-tight">LabPréstamos</div>
|
|
<div class="text-xs text-white/70">UABC · Lab. Sistemas</div>
|
|
</div>
|
|
</a>
|
|
<nav class="flex-1 p-3 flex flex-col gap-1" aria-label="Navegación principal">
|
|
{nav.map((item) => (
|
|
<a
|
|
href={item.href}
|
|
class:list={[
|
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
|
|
activeCheck(item)
|
|
? 'bg-white/15 text-white'
|
|
: 'text-white/85 hover:bg-white/10 hover:text-white'
|
|
]}
|
|
aria-current={activeCheck(item) ? 'page' : undefined}
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
|
</svg>
|
|
{item.label}
|
|
</a>
|
|
))}
|
|
</nav>
|
|
<div class="p-3 border-t border-white/10">
|
|
<div class="px-3 py-2 text-sm">
|
|
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
|
<div class="text-white/70 text-xs capitalize">{profile?.rol}</div>
|
|
</div>
|
|
<form method="POST" action="/api/auth/signout">
|
|
<button type="submit" class="w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-white/10 transition-colors flex items-center gap-2">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d={ICONS.logout} />
|
|
</svg>
|
|
Cerrar sesión
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Header mobile mínimo -->
|
|
<header class="md:hidden flex items-center justify-between px-4 py-3 bg-[color:var(--color-primary)] text-white">
|
|
<a href="/" class="flex items-center gap-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white rounded-lg" aria-label="LabPréstamos — Inicio">
|
|
<div class="w-8 h-8 rounded-lg bg-white/15 grid place-items-center p-1.5" aria-hidden="true">
|
|
<BrandMark class="w-full h-full text-white" />
|
|
</div>
|
|
<span class="font-semibold text-sm">LabPréstamos</span>
|
|
</a>
|
|
<div class="text-xs text-white/80 truncate max-w-[45%] text-right">
|
|
<div class="font-medium truncate">{profile?.nombre ?? profile?.email}</div>
|
|
<div class="text-white/60 capitalize">{profile?.rol}</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="main" class="flex-1 p-4 md:p-8 pb-28 md:pb-8 md:ml-64 sidebar-aware transition-[margin] duration-200 ease-out">
|
|
<!-- Botón hamburguesa desktop: toggle sidebar -->
|
|
<button
|
|
type="button"
|
|
id="sidebar-toggle"
|
|
aria-label="Alternar menú lateral"
|
|
aria-controls="app-sidebar"
|
|
class="hidden md:inline-flex items-center justify-center w-10 h-10 rounded-lg mb-4 text-[color:var(--color-ink)]/80 hover:bg-[color:var(--color-ink)]/5 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--color-primary)]"
|
|
>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d={ICONS.menu} />
|
|
</svg>
|
|
</button>
|
|
<slot />
|
|
</main>
|
|
|
|
<!-- Dock flotante mobile -->
|
|
<nav
|
|
class="md:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-50 rounded-full px-2 py-2 flex items-center gap-1 backdrop-blur bg-white/95 shadow-[0_10px_30px_-8px_rgba(0,0,0,0.25)] border"
|
|
style="border-color: color-mix(in oklab, var(--color-ink) 10%, transparent); padding-bottom: calc(0.5rem + env(safe-area-inset-bottom));"
|
|
aria-label="Navegación principal"
|
|
>
|
|
{nav.map((item) => {
|
|
const active = activeCheck(item);
|
|
return (
|
|
<a
|
|
href={item.href}
|
|
aria-label={item.label}
|
|
aria-current={active ? 'page' : undefined}
|
|
class:list={[
|
|
'grid place-items-center rounded-full w-11 h-11 transition-[background-color,color,transform] duration-150 motion-safe:active:scale-95',
|
|
active
|
|
? 'bg-[color:var(--color-primary)] text-white'
|
|
: 'text-[color:var(--color-ink)]/70 hover:text-[color:var(--color-ink)] hover:bg-[color:var(--color-ink)]/5'
|
|
]}
|
|
>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d={ICONS[item.icon as keyof typeof ICONS]} />
|
|
</svg>
|
|
</a>
|
|
);
|
|
})}
|
|
<span class="w-px h-6 mx-1" style="background: color-mix(in oklab, var(--color-ink) 15%, transparent);" aria-hidden="true"></span>
|
|
<form method="POST" action="/api/auth/signout" class="contents">
|
|
<button
|
|
type="submit"
|
|
aria-label="Cerrar sesión"
|
|
class="grid place-items-center rounded-full w-11 h-11 text-[color:var(--color-ink)]/70 hover:text-[color:var(--color-danger)] hover:bg-[color:var(--color-danger)]/10 transition-[background-color,color,transform] duration-150 motion-safe:active:scale-95"
|
|
>
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d={ICONS.logout} />
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
</nav>
|
|
|
|
<Toaster client:idle />
|
|
</div>
|
|
</Layout>
|
|
|
|
<style is:global>
|
|
@media (min-width: 768px) {
|
|
.sidebar-collapsed .sidebar-panel { transform: translateX(-100%); }
|
|
.sidebar-collapsed .sidebar-aware { margin-left: 0; }
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
const btn = document.getElementById('sidebar-toggle');
|
|
const root = document.documentElement;
|
|
const sync = () => {
|
|
const collapsed = root.classList.contains('sidebar-collapsed');
|
|
btn?.setAttribute('aria-expanded', String(!collapsed));
|
|
};
|
|
sync();
|
|
btn?.addEventListener('click', () => {
|
|
root.classList.toggle('sidebar-collapsed');
|
|
const collapsed = root.classList.contains('sidebar-collapsed');
|
|
try { localStorage.setItem('sidebar', collapsed ? 'collapsed' : 'open'); } catch (_) {}
|
|
sync();
|
|
});
|
|
</script>
|