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 const isPublicPage = pathname === "/" || pathname === "/docs/";32 if (!loading && (!me || !me.mfa_passed) && isPublicPage) {33 return (34 <div className="min-h-dvh">35 <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 sm:px-5">36 <Link href="/" className="flex items-center gap-2">37 <Logo />38 <span className="text-sm font-semibold">FinData OS</span>39 </Link>40 <nav className="flex items-center gap-1" aria-label="Public">41 <Link href="/" className={clsx("btn btn-ghost btn-sm", pathname === "/" && "text-accent-ink")}>Overview</Link>42 <Link href="/docs/" className={clsx("btn btn-ghost btn-sm", pathname === "/docs/" && "text-accent-ink")}>Docs</Link>43 <a href="/api/docs" className="btn btn-ghost btn-sm">API</a>44 <Link href="/login/" className="btn btn-primary btn-sm">Sign in</Link>45 </nav>46 </header>47 <main className="min-w-0 px-3 pb-10 pt-3 sm:px-5 sm:pt-4 lg:px-8 lg:pt-6">48 <div className="mx-auto max-w-[1400px]">{children}</div>49 </main>50 </div>51 );52 }5354 if (loading || !me || !me.mfa_passed) {55 return (56 <main className="flex min-h-dvh items-center justify-center p-6">57 <div className="flex items-center gap-3 text-ink-2">58 <span className="h-3 w-3 animate-pulse rounded-full bg-accent" aria-hidden />59 {loading ? "Checking session…" : "Redirecting to sign-in…"}60 </div>61 </main>62 );63 }6465 const items = NAV.filter((n) => !n.owner || isOwner);66 const groups: { key: "data" | "ops" | "system"; label: string }[] = [67 { key: "data", label: "Data" },68 { key: "ops", label: "Operations" },69 { key: "system", label: "System" },70 ];71 const tabs = items.filter((n) => MOBILE_TABS.includes(n.href)).slice(0, 4);7273 const NavList = (74 <nav className="flex flex-col gap-4" aria-label="Main">75 {groups.map((g) => {76 const gi = items.filter((n) => n.group === g.key);77 if (!gi.length) return null;78 return (79 <div key={g.key}>80 <div className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-ink-3">{g.label}</div>81 <ul className="flex flex-col gap-0.5">82 {gi.map((n) => (83 <li key={n.href}>84 <Link href={n.href} className="nav-link" aria-current={isCurrent(n.href, pathname) ? "page" : undefined}>85 <n.icon className="h-4 w-4 shrink-0" aria-hidden />86 <span>{n.label}</span>87 </Link>88 </li>89 ))}90 </ul>91 </div>92 );93 })}94 </nav>95 );9697 const Account = (98 <div className="border-t border-hairline pt-3">99 <div className="truncate px-2.5 text-xs text-ink-2" title={me.email ?? ""}>100 {me.email}101 </div>102 <div className="flex items-center justify-between px-2.5">103 <span className="pill bg-surface-2 text-ink-2">{me.role}</span>104 <button className="btn btn-ghost btn-sm" onClick={() => void logout()}>105 <LogOut className="h-3.5 w-3.5" /> Sign out106 </button>107 </div>108 </div>109 );110111 return (112 <div className="min-h-dvh lg:grid lg:grid-cols-[232px_1fr]">113 {/* Sidebar (desktop) */}114 <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">115 <Link href="/" className="flex items-center gap-2 px-2.5 py-1">116 <Logo />117 <span className="text-sm font-semibold tracking-tight">FinData OS</span>118 </Link>119 <div className="flex-1">{NavList}</div>120 {Account}121 </aside>122123 {/* Top bar (mobile/tablet) */}124 <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">125 <Link href="/" className="flex items-center gap-2">126 <Logo />127 <span className="text-sm font-semibold">FinData OS</span>128 </Link>129 <button className="btn btn-ghost px-2" aria-label="Open menu" aria-expanded={open} onClick={() => setOpen(true)}>130 <Menu className="h-5 w-5" />131 </button>132 </header>133134 {/* Drawer */}135 {open && (136 <div className="fixed inset-0 z-40 lg:hidden" role="dialog" aria-modal="true" aria-label="Navigation">137 <button className="absolute inset-0 bg-black/45" aria-label="Close menu" onClick={() => setOpen(false)} />138 <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">139 <div className="flex items-center justify-between px-2.5">140 <span className="text-sm font-semibold">Menu</span>141 <button className="btn btn-ghost px-2" aria-label="Close" onClick={() => setOpen(false)}>142 <X className="h-5 w-5" />143 </button>144 </div>145 <div className="flex-1">{NavList}</div>146 {Account}147 </div>148 </div>149 )}150151 {/* Content */}152 <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">153 <div className="mx-auto max-w-[1400px]">{children}</div>154 </main>155156 {/* Bottom tab bar (mobile) */}157 <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">158 {tabs.map((n) => {159 const cur = isCurrent(n.href, pathname);160 return (161 <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}>162 <n.icon className="h-5 w-5" aria-hidden />163 {n.label.split(" ")[0]}164 </Link>165 );166 })}167 <button className="flex flex-col items-center gap-0.5 py-2 text-[11px] text-ink-2" onClick={() => setOpen(true)}>168 <MoreHorizontal className="h-5 w-5" aria-hidden />169 More170 </button>171 </nav>172 </div>173 );174}175176function Logo() {177 return (178 <svg viewBox="0 0 32 32" className="h-6 w-6" aria-hidden>179 <rect width="32" height="32" rx="7" fill="var(--accent)" />180 <path d="M8 22V10h4v12zm6 0V14h4v8zm6 0v-5h4v5z" fill="#fff" />181 </svg>182 );183}184