Spaces:
Running
Running
| import { useEffect, useState } from 'react' | |
| import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' | |
| import { | |
| Activity, | |
| ChevronRight, | |
| ChevronsLeft, | |
| ChevronsRight, | |
| Home as HomeIcon, | |
| Library, | |
| LayoutGrid, | |
| Loader2, | |
| Menu, | |
| RefreshCw, | |
| UploadCloud, | |
| Settings as SettingsIcon, | |
| X, | |
| } from 'lucide-react' | |
| import clsx from 'clsx' | |
| import { api, invalidatePreflightCache } from '../api/client' | |
| import { useRuns } from '../store/runs' | |
| import { useGenerationQueue } from '../hooks/useTrackedGenerate' | |
| import { readSelectedProcessId, SELECTED_PROCESS_EVENT } from '../lib/selectedProcess' | |
| import { useSettings } from '../store/settings' | |
| import Banner from './Banner' | |
| type NavItem = { | |
| to: string | |
| label: string | |
| icon: typeof HomeIcon | |
| end?: boolean | |
| } | |
| // Two visual groups: primary destinations and a secondary "activity" group | |
| // for runtime-y sections. Keeps the sidebar legible without scrolling once | |
| // more pages get added. | |
| const NAV_GROUPS: { label: string; items: NavItem[] }[] = [ | |
| { | |
| label: 'Workspace', | |
| items: [ | |
| { to: '/', label: 'Home', icon: HomeIcon, end: true }, | |
| { to: '/workspace', label: 'New run', icon: LayoutGrid }, | |
| { to: '/library', label: 'Library', icon: Library }, | |
| { to: '/publish', label: 'Publish', icon: UploadCloud }, | |
| ], | |
| }, | |
| { | |
| label: 'Activity', | |
| items: [ | |
| { to: '/processes', label: 'Processes', icon: Activity }, | |
| { to: '/settings', label: 'Settings', icon: SettingsIcon }, | |
| ], | |
| }, | |
| ] | |
| const ALL_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items) | |
| const ROUTE_TITLES: Record<string, string> = { | |
| '/': 'Home', | |
| '/workspace': 'Workspace', | |
| '/workspace/text': 'Text β Video', | |
| '/workspace/html': 'HTML β Video', | |
| '/workspace/image': 'Image β Video', | |
| '/library': 'Library', | |
| '/publish': 'YouTube Publish', | |
| '/processes': 'Processes', | |
| '/settings': 'Settings', | |
| } | |
| export default function Layout() { | |
| const [mobileOpen, setMobileOpen] = useState(false) | |
| const location = useLocation() | |
| const navigate = useNavigate() | |
| const { settings, update } = useSettings() | |
| const collapsed = !!settings.sidebarCollapsed | |
| // A6: react to address-bar updates (e.g. open-in-new-tab navigation, in-app | |
| // window.history.pushState) without polling on a 500ms interval. The | |
| // popstate listener catches the back/forward case; pushstate is patched | |
| // once at module load so React Router's own pushes still flow normally. | |
| useEffect(() => { | |
| const sync = () => { | |
| const actual = `${window.location.pathname}${window.location.search}${window.location.hash}` | |
| const rendered = `${location.pathname}${location.search}${location.hash}` | |
| if (actual !== rendered) navigate(actual, { replace: true }) | |
| } | |
| sync() | |
| window.addEventListener('popstate', sync) | |
| window.addEventListener('hashchange', sync) | |
| return () => { | |
| window.removeEventListener('popstate', sync) | |
| window.removeEventListener('hashchange', sync) | |
| } | |
| }, [location.hash, location.pathname, location.search, navigate]) | |
| // Close the mobile drawer whenever the route changes. Deferring with a | |
| // 0ms timeout keeps `react-hooks/set-state-in-effect` happy (same pattern | |
| // already used in Library / Processes for their initial fetch). | |
| useEffect(() => { | |
| const t = setTimeout(() => setMobileOpen(false), 0) | |
| return () => clearTimeout(t) | |
| }, [location.pathname]) | |
| return ( | |
| <div className="relative flex min-h-full"> | |
| {/* βββ Desktop sidebar βββββββββββββββββββββββββββββββββββββββββββ */} | |
| <aside | |
| className={clsx( | |
| 'sticky top-0 hidden h-screen shrink-0 flex-col border-r transition-[width] duration-200 md:flex', | |
| collapsed ? 'w-[60px]' : 'w-64', | |
| )} | |
| style={{ borderColor: 'rgb(var(--line))', backgroundColor: 'rgb(var(--bg-surface))' }} | |
| aria-label="Primary navigation" | |
| > | |
| <Brand collapsed={collapsed} /> | |
| <SidebarNav collapsed={collapsed} /> | |
| <SidebarFooterWithProgress collapsed={collapsed} /> | |
| <button | |
| type="button" | |
| aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} | |
| title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'} | |
| onClick={() => update({ sidebarCollapsed: !collapsed })} | |
| className="absolute -right-3 top-1/2 z-20 hidden h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full border bg-[rgb(var(--bg-surface))] text-faint shadow-sm transition-colors hover:text-[rgb(var(--text-strong))] md:flex" | |
| style={{ borderColor: 'rgb(var(--line))' }} | |
| > | |
| {collapsed ? <ChevronsRight size={12} /> : <ChevronsLeft size={12} />} | |
| </button> | |
| </aside> | |
| {/* βββ Mobile drawer ββββββββββββββββββββββββββββββββββββββββββββ */} | |
| {mobileOpen && ( | |
| <div | |
| className="fixed inset-0 z-40 bg-slate-900/40 backdrop-blur-sm md:hidden" | |
| onClick={() => setMobileOpen(false)} | |
| /> | |
| )} | |
| <aside | |
| className={clsx( | |
| 'fixed inset-y-0 left-0 z-50 flex w-72 flex-col border-r transition-transform duration-200 md:hidden', | |
| mobileOpen ? 'translate-x-0' : '-translate-x-full', | |
| )} | |
| style={{ borderColor: 'rgb(var(--line))', backgroundColor: 'rgb(var(--bg-surface))' }} | |
| > | |
| <div className="flex items-center justify-between pr-2"> | |
| <Brand /> | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm mr-2" | |
| onClick={() => setMobileOpen(false)} | |
| aria-label="Close menu" | |
| > | |
| <X size={16} /> | |
| </button> | |
| </div> | |
| <SidebarNav /> | |
| <SidebarFooterWithProgress /> | |
| </aside> | |
| {/* βββ Main column ββββββββββββββββββββββββββββββββββββββββββββββ */} | |
| <div className="flex min-w-0 flex-1 flex-col"> | |
| <Topbar onOpenMenu={() => setMobileOpen(true)} /> | |
| <DocumentTitleSync /> | |
| <BackendOfflineBanner /> | |
| <main className="flex-1 px-4 pb-12 pt-6 md:px-10 md:pt-8"> | |
| <div key={location.pathname} className="animate-app-fade-in"> | |
| <Outlet /> | |
| </div> | |
| </main> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| /** | |
| * A7: write a "(N) TextBro Studio" prefix into document.title that reflects | |
| * how many runs are running + queued. Restores the original title when no | |
| * jobs are active. | |
| */ | |
| function DocumentTitleSync() { | |
| const { runs } = useRuns() | |
| const { queue } = useGenerationQueue() | |
| const running = runs.filter((r) => r.status === 'running').length | |
| const total = running + queue.length | |
| useEffect(() => { | |
| const base = 'TextBro Studio' | |
| document.title = total > 0 ? `(${total}) ${base}` : base | |
| }, [total]) | |
| return null | |
| } | |
| /** | |
| * A5: app-wide banner shown when the backend is unreachable. Replaces the | |
| * tiny sidebar status pill that was easy to miss while scrolling a long | |
| * Library or Processes page. | |
| */ | |
| function BackendOfflineBanner() { | |
| const status = useBackendStatus() | |
| const [retrying, setRetrying] = useState(false) | |
| if (status !== 'offline') return null | |
| const retry = async () => { | |
| setRetrying(true) | |
| invalidatePreflightCache() | |
| try { | |
| await api.preflight({ fresh: true }) | |
| } catch { | |
| /* the periodic poll in useBackendStatus will flip the dot */ | |
| } finally { | |
| setRetrying(false) | |
| } | |
| } | |
| return ( | |
| <div className="px-4 pt-3 md:px-10"> | |
| <Banner | |
| tone="danger" | |
| title="Backend unreachable" | |
| actions={ | |
| <> | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm shrink-0 !text-rose-900 hover:!bg-rose-100 dark:!text-rose-100 dark:hover:!bg-rose-500/10" | |
| onClick={retry} | |
| disabled={retrying} | |
| > | |
| <RefreshCw size={12} className={retrying ? 'animate-spin' : ''} /> | |
| {retrying ? 'Retryingβ¦' : 'Retry'} | |
| </button> | |
| <NavLink | |
| to="/settings" | |
| className="btn-ghost btn-sm shrink-0 !text-rose-900 hover:!bg-rose-100 dark:!text-rose-100 dark:hover:!bg-rose-500/10" | |
| > | |
| Open settings | |
| </NavLink> | |
| </> | |
| } | |
| > | |
| Generations and live progress are paused β check that the Flask server is running. | |
| </Banner> | |
| </div> | |
| ) | |
| } | |
| /* βββ Pieces βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function Brand({ collapsed = false }: { collapsed?: boolean }) { | |
| return ( | |
| <div | |
| className={clsx( | |
| 'flex items-center gap-3 py-5', | |
| collapsed ? 'justify-center px-3' : 'px-5', | |
| )} | |
| style={{ borderBottom: '1px solid rgb(var(--line-soft))' }} | |
| title={collapsed ? 'TextBro Studio' : undefined} | |
| > | |
| <div className="relative flex h-9 w-9 items-center justify-center rounded-[10px] bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-sm"> | |
| {/* Wordmark glyph β a simple geometric "T" + line that reads as a | |
| content-to-frames mark. No emoji, no sparkle. */} | |
| <svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true"> | |
| <path d="M5 6h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> | |
| <path d="M12 6v13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> | |
| <rect x="14.5" y="11.5" width="6" height="4.5" rx="1" stroke="currentColor" strokeWidth="1.6" /> | |
| </svg> | |
| </div> | |
| {!collapsed && ( | |
| <div className="leading-tight"> | |
| <div className="font-display text-[15px] font-semibold tracking-tight"> | |
| TextBro | |
| </div> | |
| <div className="text-[11px] uppercase tracking-[0.14em] text-faint"> | |
| Studio | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| function SidebarNav({ collapsed = false }: { collapsed?: boolean }) { | |
| const { runs } = useRuns() | |
| const { queue } = useGenerationQueue() | |
| // A run is "live" if at least one tracked entry is still in the running | |
| // state. Shown next to the Processes link so the user never loses sight | |
| // of an in-flight job when they navigate to Library / Settings / etc. | |
| // The queue contains pending-only items; the currently-executing run is | |
| // tracked in the runs store / live generation state. | |
| const runningCount = runs.filter((r) => r.status === 'running').length | |
| const queuedCount = queue.length | |
| const badgeCount = runningCount + queuedCount | |
| return ( | |
| <nav className={clsx('flex-1 overflow-y-auto pb-4 pt-3', collapsed ? 'px-1.5' : 'px-3')}> | |
| {NAV_GROUPS.map((group) => ( | |
| <div key={group.label} className="mb-4 last:mb-0"> | |
| {!collapsed && ( | |
| <div className="px-2 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-faint"> | |
| {group.label} | |
| </div> | |
| )} | |
| <div className="space-y-0.5"> | |
| {group.items.map((item) => { | |
| const showRunningBadge = item.to === '/processes' && badgeCount > 0 | |
| const badgeParts: string[] = [] | |
| if (runningCount > 0) badgeParts.push(`${runningCount} running`) | |
| if (queuedCount > 0) badgeParts.push(`${queuedCount} queued`) | |
| const badgeLabel = badgeParts.join(' Β· ') | |
| return ( | |
| <NavLink | |
| key={item.to} | |
| to={item.to} | |
| end={item.end} | |
| title={collapsed ? (showRunningBadge ? `${item.label} (${badgeLabel})` : item.label) : undefined} | |
| className={({ isActive }) => | |
| clsx( | |
| 'group relative flex items-center rounded-md text-[13.5px] font-medium transition-colors', | |
| collapsed ? 'h-9 justify-center px-1' : 'gap-2.5 px-2.5 py-1.5', | |
| isActive | |
| ? 'text-[rgb(var(--text-strong))]' | |
| : 'text-muted hover:text-[rgb(var(--text-strong))]', | |
| ) | |
| } | |
| style={({ isActive }) => ({ | |
| backgroundColor: isActive ? 'rgb(var(--bg-muted))' : 'transparent', | |
| })} | |
| > | |
| {({ isActive }) => ( | |
| <> | |
| {/* Active marker: a 2px brand-colored bar on the left. */} | |
| <span | |
| aria-hidden="true" | |
| className={clsx( | |
| 'absolute inset-y-1.5 left-0 w-0.5 rounded-r-full transition-opacity', | |
| isActive ? 'bg-brand-500 opacity-100' : 'opacity-0', | |
| )} | |
| /> | |
| <item.icon | |
| size={15} | |
| strokeWidth={isActive ? 2.25 : 1.75} | |
| className={clsx( | |
| 'shrink-0 transition-colors', | |
| isActive ? 'text-brand-600 dark:text-brand-300' : 'text-faint group-hover:text-[rgb(var(--text-muted))]', | |
| )} | |
| /> | |
| {!collapsed && <span className="flex-1">{item.label}</span>} | |
| {showRunningBadge && !collapsed && ( | |
| <span | |
| className="inline-flex items-center gap-1 rounded-full bg-brand-500/15 px-1.5 py-0.5 text-[10px] font-medium text-brand-700 dark:text-brand-200" | |
| title={badgeLabel} | |
| aria-label={badgeLabel} | |
| > | |
| {runningCount > 0 && <Loader2 size={10} className="animate-spin" />} | |
| {badgeCount} | |
| </span> | |
| )} | |
| {showRunningBadge && collapsed && ( | |
| <span | |
| aria-hidden="true" | |
| className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-brand-500" | |
| /> | |
| )} | |
| </> | |
| )} | |
| </NavLink> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| ))} | |
| </nav> | |
| ) | |
| } | |
| function SidebarFooterWithProgress({ collapsed = false }: { collapsed?: boolean }) { | |
| const status = useBackendStatus() | |
| const version = useVersionInfo() | |
| const { runs } = useRuns() | |
| const { state } = useGenerationQueue() | |
| const [selectedRunId, setSelectedRunId] = useState<string | null>(() => readSelectedProcessId()) | |
| useEffect(() => { | |
| const syncSelected = () => setSelectedRunId(readSelectedProcessId()) | |
| window.addEventListener(SELECTED_PROCESS_EVENT, syncSelected) | |
| window.addEventListener('storage', syncSelected) | |
| return () => { | |
| window.removeEventListener(SELECTED_PROCESS_EVENT, syncSelected) | |
| window.removeEventListener('storage', syncSelected) | |
| } | |
| }, []) | |
| const runningRuns = runs.filter((r) => r.status === 'running') | |
| const trackedRun = | |
| runningRuns.find((r) => r.id === selectedRunId || r.operationId === selectedRunId) ?? | |
| runningRuns[0] | |
| const hasLiveState = state.status === 'running' | |
| const progress = Math.max( | |
| 0, | |
| Math.min(100, trackedRun?.progress ?? state.progress ?? 0), | |
| ) | |
| const stage = trackedRun?.stage ?? state.stage | |
| const message = trackedRun?.message ?? state.message | |
| const showCurrent = hasLiveState || !!trackedRun | |
| const label = | |
| status === 'online' | |
| ? 'Backend online' | |
| : status === 'offline' | |
| ? 'Backend offline' | |
| : 'Checking backend...' | |
| if (collapsed) { | |
| return ( | |
| <div className="mt-auto" style={{ borderTop: '1px solid rgb(var(--line-soft))' }}> | |
| {showCurrent && ( | |
| <NavLink | |
| to="/processes" | |
| className="flex h-9 items-center justify-center transition-colors hover:bg-[rgb(var(--bg-muted))]" | |
| title={`Current process β ${Math.round(progress)}% β ${message || formatSidebarStage(stage)}`} | |
| aria-label={`Current process at ${Math.round(progress)}%`} | |
| > | |
| <Loader2 size={14} className="animate-spin text-brand-500" /> | |
| </NavLink> | |
| )} | |
| <NavLink | |
| to="/settings" | |
| title={status === 'offline' ? `${label} β open Settings to ping or change the URL` : label} | |
| aria-label={`${label} β open settings`} | |
| className="flex h-9 items-center justify-center transition-colors hover:bg-[rgb(var(--bg-muted))]" | |
| > | |
| <span | |
| aria-hidden="true" | |
| className={clsx( | |
| 'h-2 w-2 shrink-0 rounded-full', | |
| status === 'online' | |
| ? 'bg-emerald-500' | |
| : status === 'offline' | |
| ? 'bg-rose-500' | |
| : 'bg-slate-300 dark:bg-slate-500', | |
| )} | |
| style={status === 'online' ? { boxShadow: '0 0 0 3px rgba(16,185,129,0.18)' } : undefined} | |
| /> | |
| </NavLink> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="mt-auto" style={{ borderTop: '1px solid rgb(var(--line-soft))' }}> | |
| {showCurrent && ( | |
| <NavLink | |
| to="/processes" | |
| className="block px-4 py-3 transition-colors hover:bg-[rgb(var(--bg-muted))]" | |
| title="Open current process" | |
| > | |
| <div className="mb-1.5 flex items-center justify-between gap-2 text-[11px]"> | |
| <span className="flex min-w-0 items-center gap-1.5 font-medium text-[rgb(var(--text-strong))]"> | |
| <Loader2 size={12} className="shrink-0 animate-spin text-brand-500" /> | |
| <span className="truncate">Current process</span> | |
| </span> | |
| <span className="shrink-0 tabular-nums text-faint">{Math.round(progress)}%</span> | |
| </div> | |
| <div className="h-1.5 overflow-hidden rounded-full bg-slate-200 dark:bg-white/[0.08]"> | |
| <div | |
| className="h-full rounded-full bg-brand-500 transition-[width] duration-500" | |
| style={{ width: `${Math.max(progress, 3)}%` }} | |
| /> | |
| </div> | |
| <div className="mt-1.5 truncate text-[10px] text-muted"> | |
| {message || formatSidebarStage(stage)} | |
| </div> | |
| </NavLink> | |
| )} | |
| <NavLink | |
| to="/settings" | |
| title={ | |
| status === 'offline' | |
| ? 'Backend not reachable - open Settings to ping or change the URL' | |
| : 'Open Settings' | |
| } | |
| aria-label={`${label} - open settings`} | |
| className="flex items-center gap-2 px-4 py-3 text-[11px] transition-colors hover:bg-[rgb(var(--bg-muted))]" | |
| > | |
| <span | |
| aria-hidden="true" | |
| className={clsx( | |
| 'h-1.5 w-1.5 shrink-0 rounded-full', | |
| status === 'online' | |
| ? 'bg-emerald-500' | |
| : status === 'offline' | |
| ? 'bg-rose-500' | |
| : 'bg-slate-300 dark:bg-slate-500', | |
| )} | |
| style={status === 'online' ? { boxShadow: '0 0 0 3px rgba(16,185,129,0.18)' } : undefined} | |
| /> | |
| <span className={clsx(status === 'offline' ? 'text-rose-600 dark:text-rose-300' : 'text-muted')}> | |
| {label} | |
| </span> | |
| <span | |
| className="ml-auto font-mono text-[10px] text-faint" | |
| title={`Backend ${version.backend} Β· UI ${version.frontend}`} | |
| > | |
| {version.label} | |
| </span> | |
| </NavLink> | |
| </div> | |
| ) | |
| } | |
| function formatSidebarStage(stage: string | undefined): string { | |
| if (!stage) return 'Working...' | |
| return stage.replace(/_/g, ' ') | |
| } | |
| export function SidebarFooter() { | |
| const status = useBackendStatus() | |
| const version = useVersionInfo() | |
| const label = | |
| status === 'online' | |
| ? 'Backend online' | |
| : status === 'offline' | |
| ? 'Backend offline' | |
| : 'Checking backendβ¦' | |
| // Status row links to /settings β when offline the user gets a one-click | |
| // path to the "Ping backend" panel instead of a dead-end indicator. | |
| return ( | |
| <NavLink | |
| to="/settings" | |
| title={ | |
| status === 'offline' | |
| ? 'Backend not reachable β open Settings to ping or change the URL' | |
| : 'Open Settings' | |
| } | |
| aria-label={`${label} β open settings`} | |
| className="mt-auto flex items-center gap-2 px-4 py-3 text-[11px] transition-colors hover:bg-[rgb(var(--bg-muted))]" | |
| style={{ borderTop: '1px solid rgb(var(--line-soft))' }} | |
| > | |
| <span | |
| aria-hidden="true" | |
| className={clsx( | |
| 'h-1.5 w-1.5 shrink-0 rounded-full', | |
| status === 'online' | |
| ? 'bg-emerald-500' | |
| : status === 'offline' | |
| ? 'bg-rose-500' | |
| : 'bg-slate-300 dark:bg-slate-500', | |
| )} | |
| style={status === 'online' ? { boxShadow: '0 0 0 3px rgba(16,185,129,0.18)' } : undefined} | |
| /> | |
| <span className={clsx(status === 'offline' ? 'text-rose-600 dark:text-rose-300' : 'text-muted')}> | |
| {label} | |
| </span> | |
| <span | |
| className="ml-auto font-mono text-[10px] text-faint" | |
| title={`Backend ${version.backend} Β· UI ${version.frontend}`} | |
| > | |
| {version.label} | |
| </span> | |
| </NavLink> | |
| ) | |
| } | |
| function Topbar({ onOpenMenu }: { onOpenMenu: () => void }) { | |
| const location = useLocation() | |
| const path = location.pathname.replace(/\/+$/, '') || '/' | |
| // Build crumbs: Workspace > Text β Video, etc. | |
| const crumbs = buildCrumbs(path) | |
| return ( | |
| <header | |
| className="sticky top-0 z-30 flex h-14 items-center gap-2 px-4 backdrop-blur md:px-10" | |
| style={{ | |
| backgroundColor: 'rgb(var(--bg-app) / 0.78)', | |
| borderBottom: '1px solid rgb(var(--line-soft))', | |
| }} | |
| > | |
| <button | |
| type="button" | |
| className="btn-ghost btn-sm md:hidden" | |
| onClick={onOpenMenu} | |
| aria-label="Open menu" | |
| > | |
| <Menu size={16} /> | |
| </button> | |
| <nav aria-label="Breadcrumb" className="flex min-w-0 items-center gap-1.5 truncate text-[13px]"> | |
| {/* Hide breadcrumb on the home route β the active sidebar item already | |
| indicates "Home"; double-labelling is visual noise. The empty | |
| container preserves the topbar height so layouts don't jump | |
| between pages. */} | |
| {path !== '/' && | |
| crumbs.map((c, i) => { | |
| const last = i === crumbs.length - 1 | |
| return ( | |
| <span key={c.to} className="flex items-center gap-1.5 truncate"> | |
| {i > 0 && ( | |
| <ChevronRight size={13} className="shrink-0 text-faint" /> | |
| )} | |
| {last ? ( | |
| <span className="truncate font-medium text-[rgb(var(--text-strong))]"> | |
| {c.label} | |
| </span> | |
| ) : ( | |
| <NavLink | |
| to={c.to} | |
| className="truncate text-muted transition-colors hover:text-[rgb(var(--text-strong))]" | |
| > | |
| {c.label} | |
| </NavLink> | |
| )} | |
| </span> | |
| ) | |
| })} | |
| </nav> | |
| <div className="ml-auto flex items-center gap-2"> | |
| {/* Tiny keyboard hint β like Linear / Raycast. Decorative; no | |
| shortcut wired yet, but signals "this app expects keyboard use". */} | |
| <span className="hidden items-center gap-1 text-[11px] text-faint md:inline-flex"> | |
| <span className="kbd">β</span> | |
| <span className="kbd">K</span> | |
| </span> | |
| </div> | |
| </header> | |
| ) | |
| } | |
| /* βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| function buildCrumbs(path: string): { to: string; label: string }[] { | |
| if (path === '/') return [{ to: '/', label: 'Home' }] | |
| const segments = path.split('/').filter(Boolean) | |
| const crumbs: { to: string; label: string }[] = [{ to: '/', label: 'Home' }] | |
| let acc = '' | |
| for (const seg of segments) { | |
| acc += '/' + seg | |
| const fromMap = ROUTE_TITLES[acc] | |
| const fromNav = ALL_ITEMS.find((i) => i.to === acc)?.label | |
| crumbs.push({ | |
| to: acc, | |
| label: fromMap ?? fromNav ?? seg.replace(/-/g, ' '), | |
| }) | |
| } | |
| return crumbs | |
| } | |
| type Status = 'pending' | 'online' | 'offline' | |
| interface VersionInfo { | |
| /** 7-char git SHA of the running backend, or 'dev'. */ | |
| backend: string | |
| /** 7-char git SHA injected at frontend build time, or 'dev'. */ | |
| frontend: string | |
| /** Best-effort label, e.g. "be 2c0d461 Β· ui 9af1c3b". */ | |
| label: string | |
| } | |
| let _versionMemo: VersionInfo | null = null | |
| function useVersionInfo(): VersionInfo { | |
| // Prefer the build-time SHA (set by Vite's `define`); fall back to 'dev'. | |
| const frontend = | |
| typeof __BUILD_SHA__ === 'string' && __BUILD_SHA__ ? __BUILD_SHA__.slice(0, 7) : 'dev' | |
| const [info, setInfo] = useState<VersionInfo>(() => | |
| _versionMemo ?? { backend: 'dev', frontend, label: `ui ${frontend}` }, | |
| ) | |
| useEffect(() => { | |
| let cancelled = false | |
| if (_versionMemo) { | |
| // Defer past the effect body so this doesn't fire synchronously | |
| // during the first render (matches the deferred-setState pattern | |
| // used elsewhere β react-hooks/set-state-in-effect). | |
| const t = window.setTimeout(() => { | |
| if (cancelled) return | |
| if (_versionMemo) setInfo(_versionMemo) | |
| }, 0) | |
| return () => { | |
| cancelled = true | |
| window.clearTimeout(t) | |
| } | |
| } | |
| api | |
| .version() | |
| .then((v) => { | |
| if (cancelled) return | |
| const sha = (v.sha || 'dev').slice(0, 7) | |
| const next: VersionInfo = { | |
| backend: sha, | |
| frontend, | |
| label: sha === frontend ? sha : `be ${sha} Β· ui ${frontend}`, | |
| } | |
| _versionMemo = next | |
| setInfo(next) | |
| }) | |
| .catch(() => { | |
| // Backend unreachable β keep the frontend SHA so the user can at | |
| // least cite what UI build they're running. | |
| }) | |
| return () => { | |
| cancelled = true | |
| } | |
| }, [frontend]) | |
| return info | |
| } | |
| function useBackendStatus(): Status { | |
| const [status, setStatus] = useState<Status>('pending') | |
| useEffect(() => { | |
| let cancelled = false | |
| let timer: ReturnType<typeof setTimeout> | null = null | |
| const ping = async () => { | |
| try { | |
| await api.preflight() | |
| if (!cancelled) setStatus('online') | |
| } catch { | |
| if (!cancelled) setStatus('offline') | |
| } finally { | |
| if (!cancelled) timer = setTimeout(ping, 30_000) | |
| } | |
| } | |
| ping() | |
| return () => { | |
| cancelled = true | |
| if (timer) clearTimeout(timer) | |
| } | |
| }, []) | |
| return status | |
| } | |