SPB Git

Repository Public

mobile-shell.tsx 6.3 KB 109 lines tsx

1'use client';2import { useState } from 'react';3import Link from 'next/link';4import { usePathname } from 'next/navigation';5import { motion } from 'motion/react';6import { Ellipsis, LogOut } from 'lucide-react';7import { useVv } from '@/hooks/use-viewport';8import { usePendingCount } from '@/lib/queries';9import { BottomSheet } from '@/components/ui/bottom-sheet';10import { haptic } from '@/lib/haptic';11import { cn } from '@/lib/cn';12import { BRAND } from '@/lib/brand';13import { MOBILE_MORE, MOBILE_NAV, TITLES, activeHref } from './nav';14import { NotificationsBell } from './notifications';15import { ConnectionIndicator } from './connection-indicator';16import { ThemeSegmented } from './theme-toggle';17import { signOut } from './header';1819/**20 * Phone layout. The root is position:fixed and sized from window.visualViewport, so its bottom21 * edge is the top of the software keyboard. The floating tab bar hides while the keyboard is open22 * and on the mission screen (which has its own header and a composer at the bottom).23 */24export function MobileShell({ children }: { children: React.ReactNode }) {25  const pathname = usePathname();26  const keyboardOpen = useVv((s) => s.keyboardOpen);27  const pending = usePendingCount();28  const [more, setMore] = useState(false);29  const path = pathname.endsWith('/') ? pathname : `${pathname}/`;30  const current = activeHref(pathname);31  const detail = path === '/mission/'; // brings its own header; no tab bar32  const inMore = MOBILE_MORE.some((m) => m.href === current);33  const items = [...MOBILE_NAV.map((n) => ({ ...n, href: n.href as string | null, active: current === n.href })), { href: null, label: 'More', icon: Ellipsis, active: inMore }];34  return (35    <div className="fixed inset-x-0 flex flex-col overflow-hidden bg-bg text-[15px]" style={{ top: 'var(--vvt, 0px)', height: 'var(--vvh, 100dvh)' }} data-testid="mobile-shell" data-keyboard={keyboardOpen ? 'open' : 'closed'}>36      {!detail && (37        <header className="pt-safe shrink-0 bg-bg">38          <div className="px-safe flex h-14 items-center gap-1 pl-5 pr-2">39            <h1 className="flex-1 truncate text-[22px] font-bold tracking-tight" data-testid="page-title">{TITLES[path] ?? BRAND.product}</h1>40            <ConnectionIndicator compact className="px-2" />41            <NotificationsBell />42          </div>43        </header>44      )}45      <motion.main key={path} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ type: 'spring', stiffness: 380, damping: 34 }} className="relative flex min-h-0 flex-1 flex-col overflow-hidden">46        {children}47      </motion.main>48      {!keyboardOpen && !detail && (49        <nav aria-label="Primary" data-testid="mobile-nav" className="pointer-events-none relative z-30 -mt-7 shrink-0 bg-gradient-to-t from-bg from-55% to-transparent px-4 pb-[max(10px,env(safe-area-inset-bottom))] pt-7">50          <ul className="glass pointer-events-auto mx-auto flex h-[62px] max-w-md items-stretch rounded-full border border-card-line p-1.5 shadow-float">51            {items.map((n) => {52              const badge = n.href === '/approvals/' && pending > 0 ? pending : 0;53              const inner = (54                <>55                  {n.active && <motion.span layoutId="nav-active" className="absolute inset-0 rounded-full bg-accent/12" transition={{ type: 'spring', stiffness: 480, damping: 36 }} />}56                  <motion.span animate={n.active ? { y: [0, -4, 0], scale: [1, 1.12, 1] } : { y: 0, scale: 1 }} transition={{ duration: 0.36, ease: [0.34, 1.4, 0.64, 1] }} className="relative">57                    <n.icon className="size-[22px]" strokeWidth={n.active ? 2.4 : 1.9} aria-hidden />58                    {badge > 0 && (59                      <span data-testid="pending-badge" className="tnum absolute -right-2.5 -top-1.5 flex h-[17px] min-w-[17px] items-center justify-center rounded-full bg-info-ink px-1 text-[10.5px] font-bold leading-none text-info-fg ring-2 ring-surface">60                        {badge > 99 ? '99+' : badge}61                      </span>62                    )}63                  </motion.span>64                  <span className="relative text-[10.5px] font-semibold leading-none">{n.label}</span>65                </>66              );67              const cls = cn('relative flex h-full w-full flex-col items-center justify-center gap-1 rounded-full transition-colors', n.active ? 'text-accent' : 'text-muted');68              return (69                <li key={n.label} className="min-w-0 flex-1">70                  {n.href ? (71                    <Link href={n.href} aria-current={n.active ? 'page' : undefined} aria-label={badge ? `${n.label}, ${badge} pending` : undefined} onClick={() => haptic(6)} className={cls} data-testid={`tab-${n.label.toLowerCase()}`}>72                      {inner}73                    </Link>74                  ) : (75                    <button onClick={() => { haptic(6); setMore(true); }} aria-haspopup="dialog" className={cls} data-testid="nav-more">76                      {inner}77                    </button>78                  )}79                </li>80              );81            })}82          </ul>83        </nav>84      )}85      <BottomSheet open={more} onOpenChange={setMore} title="More" testId="more-sheet">86        <ul className="grid grid-cols-3 gap-2 px-4 pt-1">87          {MOBILE_MORE.map((n) => (88            <li key={n.href}>89              <Link href={n.href} onClick={() => setMore(false)} aria-current={current === n.href ? 'page' : undefined} className={cn('pressable flex h-[84px] flex-col items-center justify-center gap-2 rounded-md text-[13px] font-medium', current === n.href ? 'bg-accent/12 text-accent' : 'bg-surface-2 text-text')}>90                <n.icon className={cn('size-6', current === n.href ? 'text-accent' : 'text-text-2')} aria-hidden />91                {n.label}92              </Link>93            </li>94          ))}95        </ul>96        <div className="mt-5 px-4">97          <p className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</p>98          <ThemeSegmented />99        </div>100        <div className="mt-4 px-4">101          <button onClick={() => void signOut()} className="pressable flex h-12 w-full items-center justify-center gap-2 rounded-full text-sm font-medium text-text-2 hover:bg-surface-2">102            <LogOut className="size-4" aria-hidden /> Sign out103          </button>104        </div>105      </BottomSheet>106    </div>107  );108}109
Files main
Menu

Theme