Skip to content
File

Blob: src/client/components/workspace/layout.tsx

typescript208 lines
1import { lazy, Suspense, useCallback, useEffect, useState, type ReactNode } from "react";
2import { Outlet, useLocation, useNavigate, useParams } from "@tanstack/react-router";
3import { AlertCircle } from "lucide-react";
4import { STORAGE_KEYS } from "@/client/lib/constants";
5import { isResolvingWorkspace, isWorkspaceReady } from "@/client/lib/workspace-route-model";
6import { shouldBlockMemberOnlyRouteContent, shouldRedirectMemberOnlyRoute } from "@/client/lib/workspace-layout-model";
7import { WorkspaceViewProvider } from "./view-provider";
8import { useWorkspaceView } from "./use-workspace-view";
9import { Header } from "@/client/components/header";
10import { Footer } from "@/client/components/footer";
11import { Banners } from "@/client/components/ui/banners";
12import { Skeleton } from "@/client/components/ui/skeleton";
13import { Button } from "@/client/components/ui/button";
14import { useMobileDrawer } from "@/client/hooks/use-mobile-drawer";
15import { readStorageString, writeStorageString } from "@/client/lib/storage";
16import { WorkspaceLayoutModeProvider } from "@/client/components/workspace/layout-mode-context";
17 
18const Sidebar = lazy(() => import("@/client/components/sidebar/sidebar").then((mod) => ({ default: mod.Sidebar })));
19const NARROW_SHELL_CONTENT_CLASS = "mx-auto w-full max-w-7xl";
20const NARROW_SHELL_ROW_CLASS = `${NARROW_SHELL_CONTENT_CLASS} border-l border-zinc-800/60`;
21 
22function 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 
59function 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 
80function 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 
197export 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}