Skip to content
File

Blob: src/client/components/empty-workspace-view.tsx

typescript213 lines
1import { useEffect, useState } from "react";
2import { useNavigate } from "@tanstack/react-router";
3import { Plus, Loader2, AlertCircle } from "lucide-react";
4import { Button } from "@/client/components/ui/button";
5import { slugify } from "@/lib/slugify";
6import { useCreateWorkspace } from "@/client/hooks/use-create-workspace";
7import { useDocumentTitle } from "@/client/hooks/use-document-title";
8import { api } from "@/client/lib/api";
9import { resolveRootWorkspaceDecision } from "@/client/lib/root-workspace-gateway";
10import { SESSION_MODES } from "@/client/lib/constants";
11import { useAuthStore } from "@/client/stores/auth-store";
12import { waitForWorkspaceLocalHydration } from "@/client/stores/bootstrap";
13import { useWorkspaceDirectoryStore, selectMemberWorkspaces } from "@/client/stores/workspace-directory";
14import { directoryCommands } from "@/client/stores/db/workspace-directory";
15import { useWorkspaceNavigationStore, selectLastVisitedWorkspaceId } from "@/client/stores/workspace-navigation";
16import { useSharedInboxStore, selectSharedInboxItems } from "@/client/stores/shared-inbox";
17import { fetchSharedInbox } from "@/client/hooks/use-shared-inbox";
18 
19type RootViewState = "loading" | "empty" | "unavailable";
20 
21export function EmptyWorkspaceView() {
22 useDocumentTitle(undefined);
23 const navigate = useNavigate();
24 const sessionMode = useAuthStore((s) => s.sessionMode);
25 const [view, setView] = useState<RootViewState>("loading");
26 const [showForm, setShowForm] = useState(false);
27 const [name, setName] = useState("");
28 const [slug, setSlug] = useState("");
29 const { createWorkspace, isCreating } = useCreateWorkspace();
30 
31 useEffect(() => {
32 let cancelled = false;
33 
34 async function loadWorkspaceGateway() {
35 setView("loading");
36 
37 // Settle any router-driven rehydrate before reading the projection
38 // stores. Prevents a non-local -> local in-app transition from
39 // mistaking an unhydrated projection for an empty cache.
40 await waitForWorkspaceLocalHydration();
41 if (cancelled) return;
42 
43 const lastVisitedWorkspaceId = selectLastVisitedWorkspaceId(useWorkspaceNavigationStore.getState());
44 const cachedWorkspaces = selectMemberWorkspaces(useWorkspaceDirectoryStore.getState());
45 const cachedSharedItems = selectSharedInboxItems(useSharedInboxStore.getState());
46 
47 let liveWorkspaces: Awaited<ReturnType<typeof api.workspaces.list>> | null = null;
48 
49 try {
50 liveWorkspaces = await api.workspaces.list();
51 } catch {
52 // Cached workspace and inbox state still decide the root route when live listing is unavailable.
53 }
54 
55 if (cancelled) return;
56 
57 const decision = resolveRootWorkspaceDecision({
58 lastVisitedWorkspaceId,
59 cachedWorkspaces,
60 liveWorkspaces,
61 });
62 
63 if (liveWorkspaces !== null) {
64 await directoryCommands.replaceAll(liveWorkspaces);
65 }
66 
67 if (decision.kind === "redirect") {
68 navigate({
69 to: "/$workspaceSlug",
70 params: { workspaceSlug: decision.workspace.slug },
71 replace: true,
72 });
73 return;
74 }
75 
76 if (decision.kind === "empty") {
77 let sharedItems = cachedSharedItems;
78 try {
79 const response = await fetchSharedInbox();
80 sharedItems = response.items;
81 } catch {
82 // Fall back to cached inbox
83 }
84 
85 if (cancelled) return;
86 
87 if (sharedItems.length > 0) {
88 navigate({ to: "/shared-with-me", replace: true });
89 return;
90 }
91 
92 setView("empty");
93 return;
94 }
95 
96 // Offline with no cached member workspaces: check cached shared inbox
97 if (cachedSharedItems.length > 0) {
98 navigate({ to: "/shared-with-me", replace: true });
99 return;
100 }
101 
102 setView("unavailable");
103 }
104 
105 loadWorkspaceGateway();
106 
107 return () => {
108 cancelled = true;
109 };
110 }, [navigate]);
111 
112 if (view === "loading") {
113 return (
114 <div className="flex min-h-screen items-center justify-center" aria-busy="true">
115 <div className="animate-slide-up text-center">
116 <Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-zinc-500" />
117 <p className="text-sm text-zinc-400">Loading workspaces...</p>
118 </div>
119 </div>
120 );
121 }
122 
123 if (view === "unavailable") {
124 const isExpired = sessionMode === SESSION_MODES.EXPIRED;
125 
126 return (
127 <div className="flex min-h-screen items-center justify-center px-4">
128 <div className="animate-slide-up text-center">
129 <AlertCircle className="mx-auto mb-4 h-10 w-10 text-red-400" />
130 <h2 className="text-lg font-semibold text-zinc-200">Couldn't load your workspaces</h2>
131 <p className="mt-1 text-sm text-zinc-400">
132 {isExpired ? "Sign in again to refresh your workspaces." : "Check your connection and try again."}
133 </p>
134 <div className="mt-4 flex items-center justify-center gap-2">
135 <Button variant="secondary" size="sm" onClick={() => window.location.reload()}>
136 Retry
137 </Button>
138 {isExpired && (
139 <Button variant="primary" size="sm" onClick={() => navigate({ to: "/login", search: { redirect: "/" } })}>
140 Sign in
141 </Button>
142 )}
143 </div>
144 </div>
145 </div>
146 );
147 }
148 
149 return (
150 <div className="flex min-h-screen items-center justify-center">
151 <div className="animate-slide-up text-center">
152 <h2 className="text-lg font-semibold text-zinc-200">No workspaces yet</h2>
153 <p className="mt-1 text-sm text-zinc-400">Create one, or wait for an invite.</p>
154
155 {showForm ? (
156 <div className="mx-auto mt-4 flex w-64 flex-col gap-2">
157 <input
158 autoFocus
159 placeholder="Workspace name"
160 aria-label="Workspace name"
161 value={name}
162 onChange={(e) => {
163 setName(e.target.value);
164 setSlug(slugify(e.target.value));
165 }}
166 className="rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
167 />
168 <input
169 placeholder="slug"
170 aria-label="Workspace URL slug"
171 value={slug}
172 onChange={(e) => setSlug(e.target.value)}
173 className="rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-300 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
174 />
175 <div className="flex justify-end gap-2">
176 <Button
177 variant="ghost"
178 size="sm"
179 onClick={() => {
180 setShowForm(false);
181 setName("");
182 setSlug("");
183 }}
184 >
185 Cancel
186 </Button>
187 <Button
188 variant="primary"
189 size="sm"
190 onClick={() => createWorkspace(name, slug)}
191 disabled={!name.trim() || !slug.trim() || isCreating}
192 icon={isCreating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
193 >
194 Create
195 </Button>
196 </div>
197 </div>
198 ) : (
199 <Button
200 variant="primary"
201 size="sm"
202 className="mt-4"
203 onClick={() => setShowForm(true)}
204 icon={<Plus className="h-4 w-4" />}
205 >
206 Create workspace
207 </Button>
208 )}
209 </div>
210 </div>
211 );
212}