SPB Git

Repository Public

Shell.tsx 13.1 KB 196 lines tsx

1// The application frame: sidebar and top bar on a desktop, compact top bar and tab bar on a phone or tablet. The routed2// screen is the only scrolling area.3import { formatBytes } from '@spd/shared';4import { type FormEvent, type ReactNode, useEffect, useRef, useState } from 'react';5import { Link, useLocation, useSearch } from 'wouter';6import { Glyph } from '../glyphs.tsx';7import { getLocale, useT } from '../i18n.ts';8import { useAuth } from '../session.ts';9import { cx, Dialog, ProgressBar } from '../ui.tsx';10import { LanguageToggle, Mark, ThemeToggle, useSignOut } from './account-ui.tsx';11import { ADMIN, NAV, type NavItem, SETTINGS, TABS } from './nav.ts';12import { Notifications } from './Notifications.tsx';13import { Palette } from './Palette.tsx';1415const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);16const typing = (el: EventTarget | null) => el instanceof HTMLElement && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));17const initials = (name: string) => name.trim().split(/\s+/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? '').join('') || '·';1819function Quota() {20  const t = useT();21  const { state } = useAuth();22  const q = state?.quota;23  if (!q) return null;24  const ratio = q.limit > 0 ? q.used / q.limit : 0;25  const l = getLocale();26  return (27    <div className="flex flex-col gap-2" data-testid="quota">28      <div className="flex items-baseline justify-between"><span className="text-[13px] font-semibold text-ink">{t('account.quota.title')}</span>{q.limit > 0 ? <span className="text-grad text-sm font-bold tabular-nums">{Math.round(ratio * 100)} %</span> : null}</div>29      <ProgressBar value={ratio} label={t('account.quota.title')} />30      <p className={cx('text-xs', ratio >= 0.95 ? 'text-danger' : 'text-ink-3')}>{t('account.quota.usedOf', { used: formatBytes(q.used, l), limit: formatBytes(q.limit, l) })}</p>31    </div>32  );33}3435function NavLink({ item, path, onNavigate }: { item: NavItem; path: string; onNavigate?: () => void }) {36  const t = useT();37  const on = item.match(path);38  return (39    <Link href={item.href} aria-current={on ? 'page' : undefined} onClick={onNavigate}40      className={cx('press relative flex h-12 items-center gap-3 rounded-2xl px-3 text-[15px] lg:h-11 lg:text-sm', on ? 'bg-surface font-semibold text-ink shadow-(--shadow-card)' : 'font-medium text-ink-2 hover:bg-hover hover:text-ink')}>41      <span className={cx('flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px]', on ? 'grad' : '')}><Glyph name={item.icon} size={19} /></span><span className="truncate">{t(item.label)}</span>42    </Link>43  );44}4546function AccountMenu() {47  const t = useT();48  const { state } = useAuth();49  const signOut = useSignOut();50  const [open, setOpen] = useState(false);51  const wrap = useRef<HTMLDivElement>(null);52  const user = state?.user;53  useEffect(() => {54    if (!open) return;55    wrap.current?.querySelector<HTMLElement>('[role=menuitem]')?.focus();56    const out = (e: MouseEvent) => { if (!wrap.current?.contains(e.target as Node)) setOpen(false); };57    window.addEventListener('mousedown', out);58    return () => window.removeEventListener('mousedown', out);59  }, [open]);60  if (!user) return null;61  const onKey = (e: React.KeyboardEvent) => {62    const els = [...(wrap.current?.querySelectorAll<HTMLElement>('[role=menuitem]') ?? [])];63    const i = els.indexOf(document.activeElement as HTMLElement);64    if (e.key === 'ArrowDown') { e.preventDefault(); els[(i + 1) % els.length]?.focus(); }65    else if (e.key === 'ArrowUp') { e.preventDefault(); els[(i - 1 + els.length) % els.length]?.focus(); }66    else if (e.key === 'Escape') { e.stopPropagation(); setOpen(false); wrap.current?.querySelector<HTMLElement>('button')?.focus(); }67    else if (e.key === 'Tab') setOpen(false);68  };69  const item = 'flex h-10 w-full items-center gap-3 rounded-xl px-3 text-left text-sm text-ink outline-none transition hover:bg-hover focus:bg-hover';70  return (71    <div ref={wrap} className="relative">72      <button type="button" aria-haspopup="menu" aria-expanded={open} aria-label={t('account.menu.label', { name: user.display_name })} title={t('account.menu.title')} onClick={() => setOpen((o) => !o)}73        className="press flex h-11 w-11 items-center justify-center rounded-full">74        <span className="grad flex h-9 w-9 items-center justify-center rounded-full text-[13px] font-bold shadow-md ring-2 ring-surface" aria-hidden="true">{initials(user.display_name)}</span>75      </button>76      {open ? (77        <div role="menu" aria-label={t('account.menu.title')} onKeyDown={onKey} className="glass anim-pop absolute top-full right-0 z-40 mt-2 w-64 max-w-[calc(100vw-1rem)] rounded-2xl p-1.5 shadow-pop">78          <div className="px-3 py-2.5"><p className="truncate text-[15px] font-bold text-ink">{user.display_name}</p><p className="truncate text-xs text-ink-3">{user.email}</p></div>79          <div className="my-1 h-px bg-line" role="separator" />80          <Link href="/settings" role="menuitem" className={item} onClick={() => setOpen(false)}><Glyph name="settings" size={18} className="shrink-0 opacity-80" />{t('account.nav.settings')}</Link>81          <button type="button" role="menuitem" className={item} onClick={() => { setOpen(false); void signOut(); }}><Glyph name="logout" size={18} className="shrink-0 opacity-80" />{t('account.menu.signOut')}</button>82        </div>83      ) : null}84    </div>85  );86}8788function SearchBox({ onPalette }: { onPalette: () => void }) {89  const t = useT();90  const [path, navigate] = useLocation();91  const search = useSearch();92  const current = path === '/search' ? new URLSearchParams(search).get('q') ?? '' : '';93  const [value, setValue] = useState(current);94  const input = useRef<HTMLInputElement>(null);95  useEffect(() => { setValue(current); }, [current, path]);96  useEffect(() => {97    const onKey = (e: KeyboardEvent) => {98      if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey || typing(e.target) || document.querySelector('[role=dialog][aria-modal=true]')) return;99      e.preventDefault(); input.current?.focus(); input.current?.select();100    };101    window.addEventListener('keydown', onKey);102    return () => window.removeEventListener('keydown', onKey);103  }, []);104  const submit = (e: FormEvent) => { e.preventDefault(); const q = value.trim(); if (q) navigate(`/search?q=${encodeURIComponent(q)}`); };105  return (106    <form role="search" onSubmit={submit} className="relative min-w-0 flex-1 lg:max-w-xl">107      <Glyph name="search" size={18} className="pointer-events-none absolute top-1/2 left-4 z-10 -translate-y-1/2 text-ink-3" />108      <input ref={input} type="search" name="q" value={value} onChange={(e) => setValue(e.currentTarget.value)} aria-label={t('account.search.label')} placeholder={t('account.search.placeholder')} enterKeyHint="search" autoComplete="off"109        onKeyDown={(e) => { if (e.key === 'Escape') e.currentTarget.blur(); }}110        className="h-11 w-full rounded-full border border-transparent bg-surface pr-3 pl-11 text-base text-ink shadow-(--shadow-card) transition-[border-color,box-shadow] placeholder:text-ink-3 focus:border-accent focus:ring-4 focus:ring-accent/15 focus:outline-none sm:text-sm lg:pr-20 [&::-webkit-search-cancel-button]:hidden" />111      <button type="button" onClick={onPalette} aria-label={t('account.palette.open')} title={t('account.palette.open')}112        className="absolute top-1/2 right-2 hidden h-7 -translate-y-1/2 items-center rounded-full bg-hover px-2.5 text-xs font-semibold text-ink-3 transition hover:text-ink lg:flex">113        <kbd className="font-sans">{isMac ? '⌘K' : 'Ctrl K'}</kbd>114      </button>115    </form>116  );117}118119export function Shell({ children }: { children: ReactNode }) {120  const t = useT();121  const { state } = useAuth();122  const [path] = useLocation();123  const signOut = useSignOut();124  const [palette, setPalette] = useState(false);125  const [more, setMore] = useState(false);126  const admin = state?.user?.role === 'admin';127  useEffect(() => {128    const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setMore(false); setPalette((p) => !p); } };129    window.addEventListener('keydown', onKey);130    return () => window.removeEventListener('keydown', onKey);131  }, []);132  const tabs = NAV.filter((n) => TABS.includes(n.href));133  const rest = [...NAV.filter((n) => !TABS.includes(n.href)), SETTINGS, ...(admin ? [ADMIN] : [])];134  const inRest = rest.some((n) => n.match(path));135  const tab = 'press relative z-10 flex h-16 min-w-0 flex-1 flex-col items-center justify-center gap-1 text-[10.5px] font-semibold';136  const slot = inRest ? tabs.length : tabs.findIndex((n) => n.match(path));137  return (138    <div className="flex h-dvh overflow-hidden bg-bg text-ink">139      <a href="#main" onClick={(e) => { e.preventDefault(); document.getElementById('main')?.focus(); }}140        className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[80] focus:rounded-xl focus:bg-accent focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-accent-ink">{t('account.skip')}</a>141142      <aside className="hidden w-[17rem] shrink-0 flex-col lg:flex">143        <Link href="/" className="flex h-16 items-center gap-3 px-6" aria-label={t('account.home')}><Mark size={32} /><span className="text-[17px] font-bold tracking-tight">{t('common.appName')}</span></Link>144        <nav aria-label={t('account.nav.label')} className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto px-3 py-2">145          {NAV.map((n) => <NavLink key={n.href} item={n} path={path} />)}146          {admin ? <><div className="mx-3 my-2 h-px bg-line" role="separator" /><NavLink item={ADMIN} path={path} /></> : null}147        </nav>148        <div className="card m-3 px-4 py-4"><Quota /></div>149      </aside>150151      <div className="relative flex min-w-0 flex-1 flex-col">152        <header className="safe-t z-20 shrink-0 bg-bg">153          <div className="flex h-[60px] items-center gap-2 pr-2 pl-4 lg:h-16 lg:gap-3 lg:px-5">154            <Link href="/" className="press flex h-11 w-9 shrink-0 items-center justify-center lg:hidden" aria-label={t('account.home')}><Mark size={34} /></Link>155            <SearchBox onPalette={() => setPalette(true)} />156            <div className="ml-auto flex shrink-0 items-center">157              <ThemeToggle className="max-sm:hidden" />158              <Notifications />159              <AccountMenu />160            </div>161          </div>162        </header>163        <main id="main" tabIndex={-1} className="relative min-h-0 flex-1 overflow-x-hidden overflow-y-auto outline-none">{children}</main>164        <nav aria-label={t('account.nav.label')} className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3 lg:hidden" style={{ paddingBottom: 'max(env(safe-area-inset-bottom), 10px)' }}>165          <div className="glass pointer-events-auto relative mx-auto flex max-w-md rounded-[26px] shadow-pop">166            {slot >= 0 ? (167              <span aria-hidden="true" className="pointer-events-none absolute top-0 left-0 flex h-full justify-center pt-[9px] transition-transform duration-500 ease-(--ease-spring)" style={{ width: `${100 / (tabs.length + 1)}%`, transform: `translateX(${slot * 100}%)` }}>168                <span className="grad h-8 w-[58px] rounded-full shadow-[0_6px_16px_-6px_var(--grad-b)]" />169              </span>170            ) : null}171            {tabs.map((n) => {172              const on = n.match(path);173              return <Link key={n.href} href={n.href} aria-current={on ? 'page' : undefined} className={cx(tab, on ? 'text-accent' : 'text-ink-2')}><span className={cx('flex h-8 items-center transition-colors duration-300', on && 'text-white')}><Glyph name={n.icon} size={22} strokeWidth={on ? 2 : 1.7} /></span><span className="max-w-full truncate px-1">{t(n.short ?? n.label)}</span></Link>;174            })}175            <button type="button" aria-haspopup="dialog" aria-expanded={more} onClick={() => setMore(true)} className={cx(tab, inRest ? 'text-accent' : 'text-ink-2')}><span className={cx('flex h-8 items-center transition-colors duration-300', inRest && 'text-white')}><Glyph name="menu" size={22} /></span><span>{t('account.nav.more')}</span></button>176          </div>177        </nav>178      </div>179180      {more ? (181        <Dialog title={t('account.nav.more')} onClose={() => setMore(false)}>182          <div className="flex flex-col gap-4">183            <nav aria-label={t('account.nav.more')} className="-mx-1 flex flex-col gap-1 rounded-3xl bg-hover/60 p-1.5">{rest.map((n) => <NavLink key={n.href} item={n} path={path} onNavigate={() => setMore(false)} />)}</nav>184            <div className="rounded-3xl bg-hover/60 px-4 py-4"><Quota /></div>185            <div className="flex items-center justify-between gap-2">186              <div className="flex items-center gap-1"><ThemeToggle /><LanguageToggle signedIn /></div>187              <button type="button" onClick={() => { setMore(false); void signOut(); }} className="press inline-flex h-11 items-center gap-2 rounded-full px-4 text-[15px] font-semibold text-danger hover:bg-danger-soft"><Glyph name="logout" size={18} />{t('account.menu.signOut')}</button>188            </div>189          </div>190        </Dialog>191      ) : null}192      {palette ? <Palette onClose={() => setPalette(false)} /> : null}193    </div>194  );195}196
Files main
Menu

Theme