SPB Git

spb/findata

Public
7.3 KB · 184 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  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
Files · f85ef07ccaee074d0f21ee15a0887b71bc6ad458
Menu

Theme