Skip to content
File

Blob: src/client/components/sidebar/workspace-switcher.tsx

typescript222 lines
1import { useCallback, useRef, useState } from "react";
2import { useNavigate } from "@tanstack/react-router";
3import { Plus, Loader2, ChevronDown, Pencil, Check, X } from "lucide-react";
4import { useCurrentWorkspace, useWorkspaceRole } from "@/client/components/workspace/use-workspace-view";
5import { useMemberWorkspaces } from "@/client/stores/workspace-directory";
6import { directoryCommands } from "@/client/stores/db/workspace-directory";
7import { replicaCommands } from "@/client/stores/db/workspace-replica";
8import { useCreateWorkspace } from "@/client/hooks/use-create-workspace";
9import { api } from "@/client/lib/api";
10import { slugify } from "@/lib/slugify";
11import { toast } from "@/client/components/toast-store";
12import { DropdownPortal } from "@/client/components/ui/dropdown-portal";
13import { EmojiIcon } from "@/client/components/ui/emoji-icon";
14 
15export function WorkspaceSwitcher() {
16 const navigate = useNavigate();
17 const currentWorkspace = useCurrentWorkspace();
18 const isOwner = useWorkspaceRole() === "owner";
19 const workspaces = useMemberWorkspaces();
20 const [dropdownOpen, setDropdownOpen] = useState(false);
21 const [showCreateForm, setShowCreateForm] = useState(false);
22 const [createName, setCreateName] = useState("");
23 const [createSlug, setCreateSlug] = useState("");
24 const [renaming, setRenaming] = useState(false);
25 const [renameName, setRenameName] = useState("");
26 const triggerRef = useRef<HTMLButtonElement>(null);
27 
28 const { createWorkspace, isCreating: creatingWs } = useCreateWorkspace();
29 
30 const handleClose = useCallback(() => {
31 setDropdownOpen(false);
32 setShowCreateForm(false);
33 setRenaming(false);
34 }, []);
35 
36 const handleRename = useCallback(async () => {
37 if (!currentWorkspace || !renameName.trim()) return;
38 try {
39 const updated = await api.workspaces.update(currentWorkspace.id, { name: renameName.trim() });
40 // Workspace PATCH returns plain Workspace (no role). Both stores take
41 // the plain patch and preserve their role-aware fields.
42 await replicaCommands.patchWorkspaceHead(currentWorkspace.id, updated);
43 await directoryCommands.patch(currentWorkspace.id, updated);
44 } catch {
45 toast.error("Failed to rename workspace");
46 }
47 setRenaming(false);
48 }, [currentWorkspace, renameName]);
49 
50 return (
51 <>
52 <div className="flex h-10 items-center border-b border-zinc-800/60 px-1.5">
53 <button
54 ref={triggerRef}
55 onClick={() => setDropdownOpen((o) => !o)}
56 aria-expanded={dropdownOpen}
57 className={`group flex h-8 min-w-0 flex-1 items-center justify-between rounded-md border px-3 pr-3 transition-[background-color,border-color,color] ${
58 dropdownOpen
59 ? "border-zinc-700/80 bg-zinc-800/90 text-zinc-100"
60 : "border-transparent text-zinc-300 hover:border-zinc-800/80 hover:bg-zinc-800/80 hover:text-zinc-100"
61 }`}
62 >
63 <span className="truncate text-sm font-medium">
64 {currentWorkspace?.icon && (
65 <span className="mr-1.5 inline-flex items-center align-middle">
66 <EmojiIcon emoji={currentWorkspace.icon} size={14} />
67 </span>
68 )}
69 {currentWorkspace?.name ?? "Workspace"}
70 </span>
71 <ChevronDown
72 className={`h-3.5 w-3.5 shrink-0 transition-transform ${
73 dropdownOpen ? "rotate-180 text-zinc-300" : "text-zinc-500 group-hover:text-zinc-300"
74 }`}
75 />
76 </button>
77 </div>
78
79 {dropdownOpen && (
80 <DropdownPortal
81 triggerRef={triggerRef}
82 align="left"
83 widthMode="match-trigger"
84 zIndex={90}
85 onClose={handleClose}
86 className="overflow-hidden rounded-xl border-zinc-700/80 bg-[color:oklch(0.29_0.008_18)] shadow-[0_20px_45px_rgba(0,0,0,0.38)]"
87 >
88 <div className="max-h-48 overflow-y-auto p-1">
89 {workspaces.map((ws) => (
90 <div key={ws.id} className="group relative">
91 {renaming && ws.id === currentWorkspace?.id ? (
92 <form
93 className="flex items-center gap-1 px-2 py-1"
94 onSubmit={(e) => {
95 e.preventDefault();
96 handleRename();
97 }}
98 >
99 <input
100 autoFocus
101 value={renameName}
102 onChange={(e) => setRenameName(e.target.value)}
103 onKeyDown={(e) => {
104 if (e.key === "Escape") setRenaming(false);
105 }}
106 aria-label="Workspace name"
107 className="flex-1 rounded border border-zinc-600 bg-zinc-800 px-1.5 py-0.5 text-xs text-zinc-200 outline-none focus:border-accent-500"
108 />
109 <button
110 type="submit"
111 aria-label="Save workspace name"
112 className="text-zinc-400 hover:text-accent-400"
113 >
114 <Check className="h-3.5 w-3.5" aria-hidden="true" />
115 </button>
116 <button
117 type="button"
118 onClick={() => setRenaming(false)}
119 aria-label="Cancel rename"
120 className="text-zinc-400 hover:text-zinc-200"
121 >
122 <X className="h-3.5 w-3.5" aria-hidden="true" />
123 </button>
124 </form>
125 ) : (
126 <>
127 <button
128 onClick={() => {
129 setDropdownOpen(false);
130 navigate({ to: "/$workspaceSlug", params: { workspaceSlug: ws.slug } });
131 }}
132 className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-xs font-medium transition-[background-color,color,box-shadow] ${
133 ws.id === currentWorkspace?.id
134 ? "bg-[color:oklch(0.355_0.012_18)] text-zinc-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
135 : "text-zinc-400 hover:bg-[color:oklch(0.34_0.01_18)] hover:text-zinc-100"
136 }`}
137 >
138 {ws.icon && <EmojiIcon emoji={ws.icon} size={14} />}
139 <span className="truncate">{ws.name}</span>
140 </button>
141 {ws.id === currentWorkspace?.id && isOwner && (
142 <button
143 onClick={() => {
144 setRenaming(true);
145 setRenameName(ws.name);
146 }}
147 className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-1 text-zinc-500 opacity-0 transition-opacity hover:text-zinc-200 group-hover:opacity-100"
148 aria-label="Rename workspace"
149 >
150 <Pencil className="h-3 w-3" />
151 </button>
152 )}
153 </>
154 )}
155 </div>
156 ))}
157 </div>
158
159 <div className="border-t border-zinc-700/80 bg-[color:oklch(0.275_0.007_18)] p-1">
160 {showCreateForm ? (
161 <div className="flex flex-col gap-1.5 p-2">
162 <input
163 autoFocus
164 placeholder="Workspace name"
165 aria-label="New workspace name"
166 value={createName}
167 onChange={(e) => {
168 setCreateName(e.target.value);
169 setCreateSlug(slugify(e.target.value));
170 }}
171 className="rounded border border-zinc-600 bg-zinc-800 px-2 py-1 text-xs text-zinc-200 outline-none focus:border-accent-500"
172 />
173 <input
174 placeholder="slug"
175 aria-label="New workspace URL slug"
176 value={createSlug}
177 onChange={(e) => setCreateSlug(e.target.value)}
178 className="rounded border border-zinc-600 bg-zinc-800 px-2 py-1 text-xs text-zinc-300 outline-none focus:border-accent-500"
179 />
180 <div className="flex justify-end gap-1">
181 <button
182 onClick={() => {
183 setShowCreateForm(false);
184 setCreateName("");
185 setCreateSlug("");
186 }}
187 className="rounded px-2 py-1 text-xs text-zinc-400 transition-colors hover:text-zinc-200"
188 >
189 Cancel
190 </button>
191 <button
192 onClick={() =>
193 createWorkspace(createName, createSlug, () => {
194 setDropdownOpen(false);
195 setShowCreateForm(false);
196 setCreateName("");
197 setCreateSlug("");
198 })
199 }
200 disabled={!createName.trim() || !createSlug.trim() || creatingWs}
201 className="rounded bg-accent-600 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-accent-500 disabled:opacity-50"
202 >
203 {creatingWs ? <Loader2 className="h-3 w-3 animate-spin" /> : "Create"}
204 </button>
205 </div>
206 </div>
207 ) : (
208 <button
209 onClick={() => setShowCreateForm(true)}
210 className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-xs font-medium text-zinc-400 transition-[background-color,color] hover:bg-[color:oklch(0.34_0.01_18)] hover:text-zinc-100"
211 >
212 <Plus className="h-3.5 w-3.5" />
213 Create workspace
214 </button>
215 )}
216 </div>
217 </DropdownPortal>
218 )}
219 </>
220 );
221}