SPB Git

Repository Public

mobile-shell.tsx 5.9 KB 116 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 { BottomSheet } from '@/components/ui/bottom-sheet';9import { api } from '@/lib/api';10import { haptic } from '@/lib/haptic';11import { cn } from '@/lib/cn';12import { MOBILE_MORE, MOBILE_NAV, TITLES } from './nav';13import { NotificationsBell } from './notifications';14import { ConnectionIndicator } from './connection-indicator';15import { ThemeSegmented } from './theme-toggle';16import { useBrand } from '@/stores/branding';1718/**19 * Phone layout. The root is position:fixed and sized from window.visualViewport, so its bottom20 * edge is the top of the software keyboard. The floating tab bar hides while the keyboard is open.21 */22export function MobileShell({ children }: { children: React.ReactNode }) {23  const brand = useBrand();24  const pathname = usePathname();25  const keyboardOpen = useVv((s) => s.keyboardOpen);26  const [more, setMore] = useState(false);27  const path = pathname.endsWith('/') ? pathname : `${pathname}/`;28  const bare = path === '/terminal/' || path === '/home/'; // these screens bring their own header29  const inMore = MOBILE_MORE.some((m) => m.href === path);30  const items = [...MOBILE_NAV.map((n) => ({ ...n, active: path === n.href })), { href: null, label: 'More', icon: Ellipsis, active: inMore }];31  return (32    <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'}>33      {!bare && (34        <header className="pt-safe shrink-0 bg-bg">35          <div className="px-safe flex h-14 items-center gap-1 pl-5 pr-2">36            <h1 className="flex-1 truncate text-[22px] font-bold tracking-tight">{TITLES[path] ?? brand.product}</h1>37            <ConnectionIndicator compact className="px-2" />38            <NotificationsBell />39          </div>40        </header>41      )}42      <motion.main43        key={path === '/terminal/' ? 'terminal' : path}44        initial={path === '/terminal/' ? false : { opacity: 0, y: 10 }}45        animate={{ opacity: 1, y: 0 }}46        transition={{ type: 'spring', stiffness: 380, damping: 34 }}47        className="relative flex min-h-0 flex-1 flex-col overflow-hidden"48      >49        {children}50      </motion.main>51      {!keyboardOpen && (52        <nav aria-label="Primary" data-testid="mobile-nav" className={cn('pointer-events-none relative z-30 shrink-0 px-4 pb-[max(10px,env(safe-area-inset-bottom))]', path === '/terminal/' ? 'bg-surface-2 pt-1' : '-mt-7 bg-gradient-to-t from-bg from-55% to-transparent pt-7')}>53          <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">54            {items.map((n) => {55              const inner = (56                <>57                  {n.active && <motion.span layoutId="nav-active" className="absolute inset-0 rounded-full bg-accent/12" transition={{ type: 'spring', stiffness: 480, damping: 36 }} />}58                  <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">59                    <n.icon className="size-[22px]" strokeWidth={n.active ? 2.4 : 1.9} aria-hidden />60                  </motion.span>61                  <span className="relative text-[10.5px] font-semibold leading-none">{n.label}</span>62                </>63              );64              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');65              return (66                <li key={n.label} className="min-w-0 flex-1">67                  {n.href ? (68                    <Link href={n.href} aria-current={n.active ? 'page' : undefined} onClick={() => haptic(6)} className={cls}>69                      {inner}70                    </Link>71                  ) : (72                    <button onClick={() => { haptic(6); setMore(true); }} aria-haspopup="dialog" className={cls} data-testid="nav-more">73                      {inner}74                    </button>75                  )}76                </li>77              );78            })}79          </ul>80        </nav>81      )}82      <BottomSheet open={more} onOpenChange={setMore} title="More" testId="more-sheet">83        <ul className="grid grid-cols-3 gap-2 px-4 pt-1">84          {MOBILE_MORE.map((n) => (85            <li key={n.href}>86              <Link href={n.href} onClick={() => setMore(false)} aria-current={path === n.href ? 'page' : undefined} className={cn('pressable flex h-[84px] flex-col items-center justify-center gap-2 rounded-md text-[13px] font-medium', path === n.href ? 'bg-accent/12 text-accent' : 'bg-surface-2 text-text')}>87                <n.icon className={cn('size-6', path === n.href ? 'text-accent' : 'text-text-2')} aria-hidden />88                {n.label}89              </Link>90            </li>91          ))}92        </ul>93        <div className="mt-5 px-4">94          <p className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</p>95          <ThemeSegmented />96        </div>97        <div className="mt-4 px-4">98          <button99            onClick={async () => {100              try {101                await api.post('/auth/logout');102              } finally {103                api.clearCsrf();104                location.assign('/login/');105              }106            }}107            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"108          >109            <LogOut className="size-4" aria-hidden /> Sign out110          </button>111        </div>112      </BottomSheet>113    </div>114  );115}116
Files main
Menu

Theme