229 lines
8.6 KiB
TypeScript
229 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { usePathname, useRouter } from 'next/navigation';
|
|
import { AuthProvider, useAuth } from '@/features/auth/components/AuthProvider';
|
|
import { visibleNavItems, type NavItem } from '@/lib/permissions';
|
|
import type { Role } from '@/types';
|
|
|
|
const SIDEBAR_STORAGE_KEY = 'mdv.admin.sidebar.collapsed';
|
|
const SIDEBAR_ID = 'admin-sidebar';
|
|
|
|
interface SidebarToggleProps {
|
|
expanded: boolean;
|
|
onToggle: () => void;
|
|
mode: 'desktop' | 'mobile';
|
|
}
|
|
|
|
function SidebarToggle({ expanded, onToggle, mode }: SidebarToggleProps) {
|
|
const label = mode === 'mobile'
|
|
? expanded ? 'Cerrar menú de navegación' : 'Abrir menú de navegación'
|
|
: expanded ? 'Colapsar menú lateral' : 'Expandir menú lateral';
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onToggle}
|
|
aria-label={label}
|
|
aria-expanded={expanded}
|
|
aria-controls={SIDEBAR_ID}
|
|
title={label}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] focus-visible:ring-offset-2"
|
|
>
|
|
{mode === 'mobile' ? (
|
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
{expanded
|
|
? <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
|
: <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />}
|
|
</svg>
|
|
) : (
|
|
<svg aria-hidden="true" className={`h-5 w-5 transition-transform duration-300 ${expanded ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m15 18-6-6 6-6" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function NavItemRow({ item, collapsed, onNavigate }: { item: NavItem; collapsed: boolean; onNavigate: () => void }) {
|
|
const pathname = usePathname();
|
|
const active = item.href === '/' ? pathname === '/' : pathname.startsWith(item.href);
|
|
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
onClick={onNavigate}
|
|
aria-label={item.label}
|
|
aria-current={active ? 'page' : undefined}
|
|
title={collapsed ? item.label : undefined}
|
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] focus-visible:ring-offset-1 ${
|
|
collapsed ? 'lg:justify-center lg:px-2' : ''
|
|
} ${
|
|
active
|
|
? 'border-l-[3px] border-[#2D6A4F] bg-[#2D6A4F]/10 text-[#2D6A4F]'
|
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
<span aria-hidden="true" className="shrink-0 text-base">{item.icon}</span>
|
|
<span className={`truncate ${collapsed ? 'lg:hidden' : ''}`}>{item.label}</span>
|
|
{item.badge != null && item.badge > 0 && (
|
|
<span className={`ml-auto min-w-[18px] rounded-full bg-[#E76F51] px-1.5 py-0.5 text-center text-xs font-bold text-white ${collapsed ? 'lg:hidden' : ''}`}>
|
|
{item.badge}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
interface SidebarProps {
|
|
role: Role;
|
|
email: string;
|
|
collapsed: boolean;
|
|
mobileOpen: boolean;
|
|
onCloseMobile: () => void;
|
|
onLogout: () => void;
|
|
}
|
|
|
|
function Sidebar({ role, email, collapsed, mobileOpen, onCloseMobile, onLogout }: SidebarProps) {
|
|
const items = visibleNavItems(role);
|
|
|
|
return (
|
|
<aside
|
|
id={SIDEBAR_ID}
|
|
aria-label="Navegación principal"
|
|
className={`fixed inset-y-0 left-0 z-50 flex h-screen w-60 shrink-0 flex-col border-r border-gray-200 bg-white transition-[width,transform] duration-300 ease-in-out lg:sticky lg:top-0 lg:visible lg:translate-x-0 ${
|
|
mobileOpen ? 'visible translate-x-0' : 'invisible -translate-x-full'
|
|
} ${collapsed ? 'lg:w-20' : 'lg:w-60'}`}
|
|
>
|
|
<div className="relative border-b border-gray-100 px-4 py-5">
|
|
<img
|
|
src="/images/logo-main.png"
|
|
alt="mercadodevida"
|
|
className={`mx-auto h-9 w-auto object-contain ${collapsed ? 'lg:hidden' : ''}`}
|
|
/>
|
|
<span className={`hidden h-9 items-center justify-center text-2xl ${collapsed ? 'lg:flex' : ''}`} aria-hidden="true">🌿</span>
|
|
<div className="absolute right-2 top-4 lg:hidden">
|
|
<SidebarToggle expanded={mobileOpen} onToggle={onCloseMobile} mode="mobile" />
|
|
</div>
|
|
</div>
|
|
|
|
<nav aria-label="Secciones del panel" className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
|
{items.map((item) => (
|
|
<NavItemRow key={item.href} item={item} collapsed={collapsed} onNavigate={onCloseMobile} />
|
|
))}
|
|
</nav>
|
|
|
|
<div className="border-t border-gray-100 px-3 py-4">
|
|
<div className={`px-3 py-2 ${collapsed ? 'lg:hidden' : ''}`}>
|
|
<p className="truncate text-xs text-gray-400">{email}</p>
|
|
<p className="text-xs capitalize text-gray-500">{role}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onLogout}
|
|
aria-label="Cerrar sesión"
|
|
title={collapsed ? 'Cerrar sesión' : undefined}
|
|
className={`mt-1 flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#2D6A4F] ${
|
|
collapsed ? 'lg:justify-center lg:px-2' : ''
|
|
}`}
|
|
>
|
|
<svg aria-hidden="true" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75" />
|
|
</svg>
|
|
<span className={collapsed ? 'lg:hidden' : ''}>Cerrar sesión</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
function DashboardShell({ children }: { children: React.ReactNode }) {
|
|
const { user, loading, logout } = useAuth();
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const [desktopCollapsed, setDesktopCollapsed] = useState(false);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!loading && !user) router.push('/login');
|
|
}, [user, loading, router]);
|
|
|
|
useEffect(() => {
|
|
setDesktopCollapsed(localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true');
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setMobileOpen(false);
|
|
}, [pathname]);
|
|
|
|
useEffect(() => {
|
|
if (!mobileOpen) return;
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setMobileOpen(false);
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => document.removeEventListener('keydown', onKeyDown);
|
|
}, [mobileOpen]);
|
|
|
|
const toggleDesktop = () => {
|
|
setDesktopCollapsed((current) => {
|
|
const next = !current;
|
|
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next));
|
|
return next;
|
|
});
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center bg-gray-50">
|
|
<div className="text-gray-500">Cargando...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<div className="flex min-h-screen bg-gray-50">
|
|
{mobileOpen && (
|
|
<button
|
|
type="button"
|
|
aria-label="Cerrar menú de navegación"
|
|
onClick={() => setMobileOpen(false)}
|
|
className="fixed inset-0 z-40 bg-gray-900/40 backdrop-blur-[1px] lg:hidden"
|
|
/>
|
|
)}
|
|
|
|
<Sidebar
|
|
role={user.role}
|
|
email={user.email}
|
|
collapsed={desktopCollapsed}
|
|
mobileOpen={mobileOpen}
|
|
onCloseMobile={() => setMobileOpen(false)}
|
|
onLogout={logout}
|
|
/>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-gray-200 bg-white/95 px-4 backdrop-blur sm:px-6 lg:px-10">
|
|
<div className="lg:hidden">
|
|
<SidebarToggle expanded={mobileOpen} onToggle={() => setMobileOpen(true)} mode="mobile" />
|
|
</div>
|
|
<div className="hidden lg:block">
|
|
<SidebarToggle expanded={!desktopCollapsed} onToggle={toggleDesktop} mode="desktop" />
|
|
</div>
|
|
</header>
|
|
<main className="w-full px-4 py-6 sm:px-6 lg:px-10 lg:py-8">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<AuthProvider>
|
|
<DashboardShell>{children}</DashboardShell>
|
|
</AuthProvider>
|
|
);
|
|
}
|