File
Blob: src/client/components/workspace/layout.tsx
| 1 | import { lazy, Suspense, useCallback, useEffect, useState, type ReactNode } from "react"; |
| 2 | import { Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | import { AlertCircle } from "lucide-react"; |
| 4 | import { STORAGE_KEYS } from "@/client/lib/constants"; |
| 5 | import { isResolvingWorkspace, isWorkspaceReady } from "@/client/lib/workspace-route-model"; |
| 6 | import { shouldBlockMemberOnlyRouteContent, shouldRedirectMemberOnlyRoute } from "@/client/lib/workspace-layout-model"; |
| 7 | import { WorkspaceViewProvider } from "./view-provider"; |
| 8 | import { useWorkspaceView } from "./use-workspace-view"; |
| 9 | import { Header } from "@/client/components/header"; |
| 10 | import { Footer } from "@/client/components/footer"; |
| 11 | import { Banners } from "@/client/components/ui/banners"; |
| 12 | import { Skeleton } from "@/client/components/ui/skeleton"; |
| 13 | import { Button } from "@/client/components/ui/button"; |
| 14 | import { useMobileDrawer } from "@/client/hooks/use-mobile-drawer"; |
| 15 | import { readStorageString, writeStorageString } from "@/client/lib/storage"; |
| 16 | import { WorkspaceLayoutModeProvider } from "@/client/components/workspace/layout-mode-context"; |
| 17 | |
| 18 | const Sidebar = lazy(() => import("@/client/components/sidebar/sidebar").then((mod) => ({ default: mod.Sidebar }))); |
| 19 | const NARROW_SHELL_CONTENT_CLASS = "mx-auto w-full max-w-7xl"; |
| 20 | const NARROW_SHELL_ROW_CLASS = `${NARROW_SHELL_CONTENT_CLASS} border-l border-zinc-800/60`; |
| 21 | |
| 22 | function SidebarFallback({ collapsed = false }: { collapsed?: boolean }) { |
| 23 | return ( |
| 24 | <div |
| 25 | className={`hidden shrink-0 flex-col border-r border-zinc-800/60 bg-zinc-900 md:flex ${collapsed ? "w-12" : "w-[260px]"}`} |
| 26 | aria-hidden="true" |
| 27 | > |
| 28 | {collapsed ? ( |
| 29 | <div className="flex flex-1 flex-col items-center gap-2 pt-2"> |
| 30 | <Skeleton className="h-8 w-8 rounded-md" /> |
| 31 | <Skeleton className="h-8 w-8 rounded-md" /> |
| 32 | </div> |
| 33 | ) : ( |
| 34 | <> |
| 35 | <div className="flex h-10 items-center border-b border-zinc-800/60 px-3"> |
| 36 | <Skeleton className="h-3.5 w-28" /> |
| 37 | </div> |
| 38 | <div className="flex items-center gap-1 px-2 py-2"> |
| 39 | <Skeleton className="h-7 flex-1 rounded-md" /> |
| 40 | <Skeleton className="h-7 w-12 rounded-md" /> |
| 41 | </div> |
| 42 | <div className="flex-1 space-y-1 px-1 py-1"> |
| 43 | <Skeleton className="h-7 w-full rounded-md" /> |
| 44 | <Skeleton className="ml-4 h-7 w-4/5 rounded-md" /> |
| 45 | <Skeleton className="ml-4 h-7 w-3/5 rounded-md" /> |
| 46 | <Skeleton className="h-7 w-full rounded-md" /> |
| 47 | <Skeleton className="h-7 w-4/5 rounded-md" /> |
| 48 | </div> |
| 49 | <div className="space-y-1 border-t border-zinc-800/60 px-2 py-2"> |
| 50 | <Skeleton className="h-7 w-24 rounded-md" /> |
| 51 | <Skeleton className="h-7 w-20 rounded-md" /> |
| 52 | </div> |
| 53 | </> |
| 54 | )} |
| 55 | </div> |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | function TerminalRouteError({ message, onRetry }: { message: string; onRetry?: () => void }) { |
| 60 | return ( |
| 61 | <div className="flex h-full items-center justify-center"> |
| 62 | <div className="text-center"> |
| 63 | <AlertCircle className="mx-auto mb-3 h-8 w-8 text-red-400" /> |
| 64 | <p className="mb-4 text-sm text-zinc-400">{message}</p> |
| 65 | <div className="flex items-center justify-center gap-3"> |
| 66 | {onRetry && ( |
| 67 | <Button variant="secondary" size="sm" onClick={onRetry}> |
| 68 | Try again |
| 69 | </Button> |
| 70 | )} |
| 71 | <Button variant="secondary" size="sm" onClick={() => (window.location.href = "/")}> |
| 72 | Go home |
| 73 | </Button> |
| 74 | </div> |
| 75 | </div> |
| 76 | </div> |
| 77 | ); |
| 78 | } |
| 79 | |
| 80 | function WorkspaceLayoutInner() { |
| 81 | const { route, canonicalSlug } = useWorkspaceView(); |
| 82 | const params = useParams({ strict: false }) as { workspaceSlug: string; pageId?: string }; |
| 83 | const location = useLocation(); |
| 84 | const navigate = useNavigate(); |
| 85 | const { open: mobileDrawerOpen, close: closeMobileDrawer, toggle: toggleMobileDrawer } = useMobileDrawer(); |
| 86 | const [expanded, setExpanded] = useState(() => readStorageString(STORAGE_KEYS.LAYOUT) === "expanded"); |
| 87 | const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { |
| 88 | if (typeof window === "undefined") return false; |
| 89 | const stored = readStorageString(STORAGE_KEYS.SIDEBAR); |
| 90 | if (stored !== null) return stored === "true"; |
| 91 | return window.innerWidth < 768; |
| 92 | }); |
| 93 | const [sidebarManualToggle, setSidebarManualToggle] = useState(() => { |
| 94 | if (typeof window === "undefined") return false; |
| 95 | return readStorageString(STORAGE_KEYS.SIDEBAR) !== null; |
| 96 | }); |
| 97 | |
| 98 | const toggleLayout = useCallback(() => { |
| 99 | setExpanded((prev) => { |
| 100 | const next = !prev; |
| 101 | writeStorageString(STORAGE_KEYS.LAYOUT, next ? "expanded" : "default"); |
| 102 | return next; |
| 103 | }); |
| 104 | }, []); |
| 105 | |
| 106 | useEffect(() => { |
| 107 | if (sidebarManualToggle) return; |
| 108 | const mq = window.matchMedia("(max-width: 767px)"); |
| 109 | const handleChange = (event: MediaQueryListEvent) => setSidebarCollapsed(event.matches); |
| 110 | mq.addEventListener("change", handleChange); |
| 111 | return () => mq.removeEventListener("change", handleChange); |
| 112 | }, [sidebarManualToggle]); |
| 113 | |
| 114 | const setPersistedSidebarCollapsed = useCallback((next: boolean) => { |
| 115 | setSidebarManualToggle(true); |
| 116 | setSidebarCollapsed(next); |
| 117 | writeStorageString(STORAGE_KEYS.SIDEBAR, String(next)); |
| 118 | }, []); |
| 119 | |
| 120 | // Canonical slug redirect — preserves search and hash state. |
| 121 | useEffect(() => { |
| 122 | if (!canonicalSlug) return; |
| 123 | const segments = location.pathname.split("/"); |
| 124 | if (segments[1] === canonicalSlug) return; |
| 125 | segments[1] = canonicalSlug; |
| 126 | const corrected = segments.join("/") + location.searchStr + (location.hash ? `#${location.hash}` : ""); |
| 127 | navigate({ to: corrected, replace: true }); |
| 128 | }, [canonicalSlug, location.pathname, location.searchStr, location.hash, navigate]); |
| 129 | |
| 130 | // Member-access gating: routes without a pageId param (workspace index, |
| 131 | // settings) require confirmed member access. |
| 132 | const isMemberOnlyRoute = !params.pageId; |
| 133 | const memberOnlyRouteBlocked = shouldBlockMemberOnlyRouteContent(route, isMemberOnlyRoute); |
| 134 | useEffect(() => { |
| 135 | if (shouldRedirectMemberOnlyRoute(route, isMemberOnlyRoute)) { |
| 136 | navigate({ to: "/", replace: true }); |
| 137 | } |
| 138 | }, [route, isMemberOnlyRoute, navigate]); |
| 139 | |
| 140 | let mainContent: ReactNode; |
| 141 | if (memberOnlyRouteBlocked) { |
| 142 | // Member-only route without confirmed member access. The redirect effect |
| 143 | // above will swap us onto "/"; during that one render, show nothing. |
| 144 | mainContent = null; |
| 145 | } else if (route.phase === "error") { |
| 146 | mainContent = ( |
| 147 | <TerminalRouteError |
| 148 | message={route.message} |
| 149 | onRetry={route.errorKind === "network" ? () => window.location.reload() : undefined} |
| 150 | /> |
| 151 | ); |
| 152 | } else { |
| 153 | // Ready, loading, or degraded (with a pageId — memberOnlyRouteBlocked |
| 154 | // already trapped the non-page case). ActivePageProvider owns cache-vs- |
| 155 | // live fallback for the degraded branch via its internal page-load target. |
| 156 | mainContent = <Outlet />; |
| 157 | } |
| 158 | |
| 159 | const showFullSidebar = isWorkspaceReady(route); |
| 160 | const showSkeleton = isResolvingWorkspace(route); |
| 161 | return ( |
| 162 | <WorkspaceLayoutModeProvider expanded={expanded}> |
| 163 | <a |
| 164 | href="#main-content" |
| 165 | className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[200] focus:rounded-lg focus:bg-zinc-800 focus:px-4 focus:py-2 focus:text-accent-400 focus:ring-2 focus:ring-accent-500/50" |
| 166 | > |
| 167 | Skip to content |
| 168 | </a> |
| 169 | <Header |
| 170 | expanded={expanded} |
| 171 | onToggleLayout={toggleLayout} |
| 172 | onToggleMobileSidebar={showFullSidebar ? toggleMobileDrawer : undefined} |
| 173 | /> |
| 174 | <Banners /> |
| 175 | <div className={`flex flex-1 overflow-hidden ${expanded ? "" : NARROW_SHELL_ROW_CLASS}`}> |
| 176 | {showFullSidebar ? ( |
| 177 | <Suspense fallback={<SidebarFallback collapsed={sidebarCollapsed} />}> |
| 178 | <Sidebar |
| 179 | collapsed={sidebarCollapsed} |
| 180 | onCollapsedChange={setPersistedSidebarCollapsed} |
| 181 | mobileOpen={mobileDrawerOpen} |
| 182 | onMobileClose={closeMobileDrawer} |
| 183 | /> |
| 184 | </Suspense> |
| 185 | ) : showSkeleton ? ( |
| 186 | <SidebarFallback collapsed={sidebarCollapsed} /> |
| 187 | ) : null} |
| 188 | <main id="main-content" tabIndex={-1} className="flex-1 overflow-y-auto outline-none"> |
| 189 | {mainContent} |
| 190 | </main> |
| 191 | </div> |
| 192 | <Footer expanded={expanded} /> |
| 193 | </WorkspaceLayoutModeProvider> |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | export function WorkspaceLayout() { |
| 198 | const { workspaceSlug, pageId } = useParams({ strict: false }) as { workspaceSlug: string; pageId?: string }; |
| 199 | |
| 200 | return ( |
| 201 | <div className="flex h-screen flex-col"> |
| 202 | <WorkspaceViewProvider key={workspaceSlug} workspaceSlug={workspaceSlug} pageId={pageId ?? null}> |
| 203 | <WorkspaceLayoutInner /> |
| 204 | </WorkspaceViewProvider> |
| 205 | </div> |
| 206 | ); |
| 207 | } |