1"use client";2import { useEffect, useState, type ReactNode } from "react";3import Link from "next/link";4import { usePathname } from "next/navigation";5import clsx from "clsx";6import { LogOut, Menu, MoreHorizontal, X } from "lucide-react";7import { useAuth } from "@/lib/auth";8import { MOBILE_TABS, NAV } from "./nav";910function normalize(p: string | null): string {11 if (!p) return "/";12 const s = p.endsWith("/") ? p : p + "/";13 return s;14}1516function isCurrent(href: string, path: string): boolean {17 if (href === "/") return path === "/";18 return path.startsWith(href);19}2021export function Shell({ children }: { children: ReactNode }) {22 const { me, loading, logout, isOwner } = useAuth();23 const pathname = normalize(usePathname());24 const [open, setOpen] = useState(false);25 const isLogin = pathname === "/login/";2627 useEffect(() => setOpen(false), [pathname]);2829 if (isLogin) return <main className="min-h-dvh">{children}</main>;3031 if (loading || !me || !me.mfa_passed) {32 return (33 <main className="flex min-h-dvh items-center justify-center p-6">34 <div className="flex items-center gap-3 text-ink-2">35 <span className="h-3 w-3 animate-pulse rounded-full bg-accent" aria-hidden />36 {loading ? "Checking session…" : "Redirecting to sign-in…"}37 </div>38 </main>39 );40 }4142 const items = NAV.filter((n) => !n.owner || isOwner);43 const groups: { key: "data" | "ops" | "system"; label: string }[] = [44 { key: "data", label: "Data" },45 { key: "ops", label: "Operations" },46 { key: "system", label: "System" },47 ];48 const tabs = items.filter((n) => MOBILE_TABS.includes(n.href)).slice(0, 4);4950 const NavList = (51 <nav className="flex flex-col gap-4" aria-label="Main">52 {groups.map((g) => {53 const gi = items.filter((n) => n.group === g.key);54 if (!gi.length) return null;55 return (56 <div key={g.key}>57 <div className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-ink-3">{g.label}</div>58 <ul className="flex flex-col gap-0.5">59 {gi.map((n) => (60 <li key={n.href}>61 <Link href={n.href} className="nav-link" aria-current={isCurrent(n.href, pathname) ? "page" : undefined}>62 <n.icon className="h-4 w-4 shrink-0" aria-hidden />63 <span>{n.label}</span>64 </Link>65 </li>66 ))}67 </ul>68 </div>69 );70 })}71 </nav>72 );7374 const Account = (75 <div className="border-t border-hairline pt-3">76 <div className="truncate px-2.5 text-xs text-ink-2" title={me.email ?? ""}>77 {me.email}78 </div>79 <div className="flex items-center justify-between px-2.5">80 <span className="pill bg-surface-2 text-ink-2">{me.role}</span>81 <button className="btn btn-ghost btn-sm" onClick={() => void logout()}>82 <LogOut className="h-3.5 w-3.5" /> Sign out83 </button>84 </div>85 </div>86 );8788 return (89 <div className="min-h-dvh lg:grid lg:grid-cols-[232px_1fr]">90 {/* Sidebar (desktop) */}91 <aside className="sticky top-0 hidden h-dvh flex-col gap-4 overflow-y-auto border-r border-hairline bg-surface-1 p-3 lg:flex">92 <Link href="/" className="flex items-center gap-2 px-2.5 py-1">93 <Logo />94 <span className="text-sm font-semibold tracking-tight">FinData OS</span>95 </Link>96 <div className="flex-1">{NavList}</div>97 {Account}98 </aside>99100 {/* Top bar (mobile/tablet) */}101 <header className="sticky top-0 z-30 flex items-center justify-between border-b border-hairline bg-surface-1/95 px-3 py-2 backdrop-blur lg:hidden">102 <Link href="/" className="flex items-center gap-2">103 <Logo />104 <span className="text-sm font-semibold">FinData OS</span>105 </Link>106 <button className="btn btn-ghost px-2" aria-label="Open menu" aria-expanded={open} onClick={() => setOpen(true)}>107 <Menu className="h-5 w-5" />108 </button>109 </header>110111 {/* Drawer */}112 {open && (113 <div className="fixed inset-0 z-40 lg:hidden" role="dialog" aria-modal="true" aria-label="Navigation">114 <button className="absolute inset-0 bg-black/45" aria-label="Close menu" onClick={() => setOpen(false)} />115 <div className="absolute inset-y-0 right-0 flex w-[82%] max-w-xs flex-col gap-4 overflow-y-auto bg-surface-1 p-3 shadow-xl">116 <div className="flex items-center justify-between px-2.5">117 <span className="text-sm font-semibold">Menu</span>118 <button className="btn btn-ghost px-2" aria-label="Close" onClick={() => setOpen(false)}>119 <X className="h-5 w-5" />120 </button>121 </div>122 <div className="flex-1">{NavList}</div>123 {Account}124 </div>125 </div>126 )}127128 {/* Content */}129 <main className="min-w-0 px-3 pb-20 pt-3 sm:px-5 sm:pt-4 lg:px-8 lg:pb-8 lg:pt-6">130 <div className="mx-auto max-w-[1400px]">{children}</div>131 </main>132133 {/* Bottom tab bar (mobile) */}134 <nav className="fixed inset-x-0 bottom-0 z-30 grid grid-cols-5 border-t border-hairline bg-surface-1/95 pb-[env(safe-area-inset-bottom)] backdrop-blur lg:hidden" aria-label="Quick">135 {tabs.map((n) => {136 const cur = isCurrent(n.href, pathname);137 return (138 <Link key={n.href} href={n.href} className={clsx("flex flex-col items-center gap-0.5 py-2 text-[11px]", cur ? "text-accent-ink" : "text-ink-2")} aria-current={cur ? "page" : undefined}>139 <n.icon className="h-5 w-5" aria-hidden />140 {n.label.split(" ")[0]}141 </Link>142 );143 })}144 <button className="flex flex-col items-center gap-0.5 py-2 text-[11px] text-ink-2" onClick={() => setOpen(true)}>145 <MoreHorizontal className="h-5 w-5" aria-hidden />146 More147 </button>148 </nav>149 </div>150 );151}152153function Logo() {154 return (155 <svg viewBox="0 0 32 32" className="h-6 w-6" aria-hidden>156 <rect width="32" height="32" rx="7" fill="var(--accent)" />157 <path d="M8 22V10h4v12zm6 0V14h4v8zm6 0v-5h4v5z" fill="#fff" />158 </svg>159 );160}161