SPB Git

Repository Public

app-frame.tsx 4.2 KB 101 lines tsx

1'use client';2import { useEffect, useRef } from 'react';3import { useRouter } from 'next/navigation';4import { useQueryClient } from '@tanstack/react-query';5import { WifiOff } from 'lucide-react';6import { useAuthStatus, 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 { useUi } from '@/stores/ui';12import { useConn } from '@/stores/conn';13import { Spinner } from '@/components/ui/misc';14import { Button } from '@/components/ui/button';15import { NewMissionDialog } from '@/components/missions/new-mission';16import { BRAND } from '@/lib/brand';17import { DesktopShell } from './desktop-shell';18import { MobileShell } from './mobile-shell';19import { CommandPalette } from './command-palette';20import { Logo } from './logo';2122/** Authenticated application frame: auth gate, live events, responsive shell, global overlays. */23export function AppFrame({ children }: { children: React.ReactNode }) {24  const auth = useAuthStatus();25  const router = useRouter();26  const bp = useBreakpoint();27  const online = useConn((s) => s.online);28  useVisualViewport();2930  const status = auth.data;31  const authed = !!status?.authenticated;32  useEffect(() => {33    if (!status || status.authenticated) return;34    router.replace(status.setupRequired ? '/setup/' : `/login/?next=${encodeURIComponent(location.pathname + location.search)}`);35  }, [status, router]);3637  if (!authed || !bp) {38    const offline = auth.isError && !online;39    const limited = auth.error instanceof ApiRequestError && auth.error.status === 429 ? auth.error : null;40    return (41      <div className="app-viewport flex flex-col items-center justify-center gap-4 px-6 text-center" data-testid="boot">42        <Logo className="size-10" />43        {auth.isError ? (44          <>45            {offline && <WifiOff className="size-5 text-muted" aria-hidden />}46            <p className="text-sm font-medium" data-testid="offline-state">{offline ? 'Offline — missions keep running on the server' : limited ? 'Too many requests' : 'The server is not reachable'}</p>47            <p className="max-w-sm text-xs text-text-2">48              {offline49                ? 'This device has no network connection. Agents are not affected; pending approvals will be waiting when you reconnect.'50                : limited51                  ? `The server is rate-limiting this address${limited.retryAfterSec ? ` — try again in ${limited.retryAfterSec} s` : ''}. Running missions are not affected.`52                  : `${BRAND.product} could not contact its API. Missions that were running keep running on the host.`}53            </p>54            <Button onClick={() => auth.refetch()} loading={auth.isFetching}>Retry</Button>55          </>56        ) : (57          <Spinner />58        )}59      </div>60    );61  }62  return <Authed mobile={bp === 'mobile'}>{children}</Authed>;63}6465function Authed({ mobile, children }: { mobile: boolean; children: React.ReactNode }) {66  const qc = useQueryClient();67  const setUi = useUi((s) => s.set);68  // The theme stored on the server follows the owner across devices: applied once per page load.69  const settings = useSettingsQuery().data;70  const themeApplied = useRef(false);71  useEffect(() => {72    if (!settings || themeApplied.current) return;73    themeApplied.current = true;74    if (settings.theme !== useTheme.getState().pref) useTheme.getState().setPref(settings.theme, { animate: false });75  }, [settings]);7677  useEffect(() => {78    startEvents(qc);79    return () => stopEvents();80  }, [qc]);8182  useEffect(() => {83    const onKey = (e: KeyboardEvent) => {84      if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {85        e.preventDefault();86        setUi({ paletteOpen: !useUi.getState().paletteOpen });87      }88    };89    window.addEventListener('keydown', onKey, true);90    return () => window.removeEventListener('keydown', onKey, true);91  }, [setUi]);9293  return (94    <>95      {mobile ? <MobileShell>{children}</MobileShell> : <DesktopShell>{children}</DesktopShell>}96      <CommandPalette />97      <NewMissionDialog />98    </>99  );100}101
Files main
Menu

Theme