SPB Git

Repository Public

app-frame.tsx 4.9 KB 111 lines tsx

1'use client';2import { useEffect } from 'react';3import { useRouter } from 'next/navigation';4import { useQueryClient } from '@tanstack/react-query';5import { WifiOff } from 'lucide-react';6import { useAuthStatus, useSessions, useSettingsQuery } from '@/lib/queries';7import { useTheme } from '@/stores/theme';8import { ApiRequestError } from '@/lib/api';9import { startEvents, stopEvents } from '@/lib/events';10import { useBreakpoint, useVisualViewport } from '@/hooks/use-viewport';11import { useLayoutPersistence } from '@/hooks/use-layout-persistence';12import { useUi } from '@/stores/ui';13import { useConn } from '@/stores/conn';14import { useTerminals } from '@/stores/terminals';15import { Spinner } from '@/components/ui/misc';16import { Button } from '@/components/ui/button';17import { NewSessionDialog } from '@/components/sessions/new-session-dialog';18import { DesktopShell } from './desktop-shell';19import { MobileShell } from './mobile-shell';20import { CommandPalette } from './command-palette';21import { SessionSwitcher } from './session-switcher';22import { Logo } from './logo';23import { useBrand } from '@/stores/branding';2425/** Authenticated application frame: auth gate, live events, responsive shell, global overlays. */26export function AppFrame({ children }: { children: React.ReactNode }) {27  const brand = useBrand();28  const auth = useAuthStatus();29  const router = useRouter();30  const bp = useBreakpoint();31  const online = useConn((s) => s.online);32  useVisualViewport();3334  const status = auth.data;35  const authed = !!status?.authenticated;36  useEffect(() => {37    if (!status || status.authenticated) return;38    router.replace(status.setupRequired ? '/setup/' : `/login/?next=${encodeURIComponent(location.pathname + location.search)}`);39  }, [status, router]);4041  if (!authed || !bp) {42    const offline = auth.isError && !online;43    const limited = auth.error instanceof ApiRequestError && auth.error.status === 429 ? auth.error : null;44    return (45      <div className="app-viewport flex flex-col items-center justify-center gap-4 px-6 text-center" data-testid="boot">46        <Logo className="size-10" />47        {auth.isError ? (48          <>49            {offline && <WifiOff className="size-5 text-muted" aria-hidden />}50            <p className="text-sm font-medium" data-testid="offline-state">{offline ? 'Offline — the remote session keeps running on the server' : limited ? 'Too many requests' : 'The server is not reachable'}</p>51            <p className="max-w-sm text-xs text-text-2">{offline ? 'This device has no network connection. Your terminals are not affected; reconnect to attach again.' : limited ? `The server is rate-limiting this address${limited.retryAfterSec ? ` — try again in ${limited.retryAfterSec} s` : ''}. Running sessions are not affected.` : `${brand.product} could not contact its API. Sessions that were running keep running on the host.`}</p>52            <Button onClick={() => auth.refetch()} loading={auth.isFetching}>Retry</Button>53          </>54        ) : (55          <Spinner />56        )}57      </div>58    );59  }60  return <Authed mobile={bp === 'mobile'}>{children}</Authed>;61}6263function Authed({ mobile, children }: { mobile: boolean; children: React.ReactNode }) {64  const qc = useQueryClient();65  const setUi = useUi((s) => s.set);66  const sessions = useSessions().data;67  useLayoutPersistence();68  // The server-side theme applies only once it was chosen in this UI (older stored settings always said 'dark').69  const settings = useSettingsQuery().data;70  const hydrated = useUi((s) => s.hydrated);71  const themeSet = useUi((s) => s.themeSet);72  useEffect(() => {73    if (hydrated && themeSet && settings && settings.theme !== useTheme.getState().pref) useTheme.getState().setPref(settings.theme, { animate: false });74  }, [hydrated, themeSet, settings]);7576  useEffect(() => {77    startEvents(qc);78    return () => stopEvents();79  }, [qc]);8081  // Tabs restored from localStorage may point at sessions that no longer exist.82  useEffect(() => {83    if (sessions) useTerminals.getState().prune(new Set(sessions.map((s) => s.id)));84  }, [sessions]);8586  useEffect(() => {87    const onKey = (e: KeyboardEvent) => {88      const primary = e.metaKey || e.ctrlKey;89      const k = e.key.toLowerCase();90      if (primary && !e.altKey && !e.shiftKey && k === 'k') {91        e.preventDefault();92        setUi({ paletteOpen: !useUi.getState().paletteOpen, switcherOpen: false });93      } else if ((primary && !e.altKey && !e.shiftKey && k === 'j') || (e.ctrlKey && e.key === '`')) {94        e.preventDefault();95        setUi({ switcherOpen: !useUi.getState().switcherOpen, paletteOpen: false });96      }97    };98    window.addEventListener('keydown', onKey, true);99    return () => window.removeEventListener('keydown', onKey, true);100  }, [setUi]);101102  return (103    <>104      {mobile ? <MobileShell>{children}</MobileShell> : <DesktopShell>{children}</DesktopShell>}105      <CommandPalette />106      <SessionSwitcher />107      <NewSessionDialog />108    </>109  );110}111
Files main
Menu

Theme