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