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 = { '/': '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 (
{/* ─── Desktop sidebar ─────────────────────────────────────────── */} {/* ─── Mobile drawer ──────────────────────────────────────────── */} {mobileOpen && (
setMobileOpen(false)} /> )} {/* ─── Main column ────────────────────────────────────────────── */}
setMobileOpen(true)} />
) } /** * 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 (
Open settings } > Generations and live progress are paused — check that the Flask server is running.
) } /* ─── Pieces ─────────────────────────────────────────────────────────── */ function Brand({ collapsed = false }: { collapsed?: boolean }) { return (
{/* Wordmark glyph — a simple geometric "T" + line that reads as a content-to-frames mark. No emoji, no sparkle. */}
{!collapsed && (
TextBro
Studio
)}
) } 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 ( ) } function SidebarFooterWithProgress({ collapsed = false }: { collapsed?: boolean }) { const status = useBackendStatus() const version = useVersionInfo() const { runs } = useRuns() const { state } = useGenerationQueue() const [selectedRunId, setSelectedRunId] = useState(() => 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 (
{showCurrent && ( )}
) } return (
{showCurrent && (
Current process {Math.round(progress)}%
{message || formatSidebarStage(stage)}
)}
) } 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 ( ) } function Topbar({ onOpenMenu }: { onOpenMenu: () => void }) { const location = useLocation() const path = location.pathname.replace(/\/+$/, '') || '/' // Build crumbs: Workspace > Text → Video, etc. const crumbs = buildCrumbs(path) return (
{/* Tiny keyboard hint — like Linear / Raycast. Decorative; no shortcut wired yet, but signals "this app expects keyboard use". */} K
) } /* ─── 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(() => _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('pending') useEffect(() => { let cancelled = false let timer: ReturnType | 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 }