SPB Git

spb/findata

Public
6.1 KB · 161 lines tsx
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
Files · 6437fd798ded13a52b8fed8b7d8e78b700fbceec
Menu

Theme