Files
labre-web/src/layouts/AppLayout.astro
T
LakG 57b07b9b82 Rediseño impeccable: brand mark, páginas 403/404, DESIGN.md + PRODUCT.md
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>
2026-08-16 15:19:57 -07:00

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>