Repository Public
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