Skip to content
File

Blob: src/client/components/workspace/share-dialog/share-tab.tsx

typescript314 lines
1import { useRef } from "react";
2import { Check, ChevronDown, Copy, Link2, Loader2, Trash2, Users } from "lucide-react";
3 
4import { Button } from "@/client/components/ui/button";
5import { DropdownPortal } from "@/client/components/ui/dropdown-portal";
6import { isActionEnabled, isActionVisible } from "@/client/lib/affordance/action-state";
7import { deriveShareDialogRowAffordance } from "@/client/lib/affordance/share-dialog";
8import type { PageShare } from "@/shared/types";
9 
10import { useShareDialogShell, useShareLink, useSharePeople } from "./context";
11import type { SharePermission, WorkspaceRole } from "./types";
12 
13function PermissionSelect({ value, onChange }: { value: SharePermission; onChange: (v: SharePermission) => void }) {
14 return (
15 <div className="relative shrink-0">
16 <select
17 value={value}
18 onChange={(e) => onChange(e.target.value as SharePermission)}
19 className="appearance-none rounded-md border border-zinc-700 bg-zinc-800 py-1 pl-2 pr-6 text-sm text-zinc-300 outline-none focus:border-zinc-600"
20 >
21 <option value="view">View</option>
22 <option value="edit">Edit</option>
23 </select>
24 <ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 h-3 w-3 -translate-y-1/2 text-zinc-500" />
25 </div>
26 );
27}
28 
29export function SharePeopleSection() {
30 const { creating, dialogAffordance, workspaceRole, online, currentUserId, deleteShare } = useShareDialogShell();
31 const {
32 peopleInput,
33 peoplePermission,
34 showSuggestions,
35 userShares,
36 members,
37 filteredSuggestions,
38 hasMatchedMember,
39 memberName,
40 granteeName,
41 setPeopleInput,
42 setPeoplePermission,
43 openSuggestions,
44 dismissSuggestions,
45 selectMember,
46 submitPeopleShare,
47 } = useSharePeople();
48 
49 // Member role cannot invite by free-form email; the worker rejects it.
50 // Surface that as an affordance: placeholder says "search", and submit
51 // stays disabled until the input resolves to an actual workspace member.
52 const placeholder = dialogAffordance.shareByEmail ? "Name or email..." : "Teammate...";
53 const ariaLabel = dialogAffordance.shareByEmail
54 ? "Search people to share with"
55 : "Search workspace members to share with";
56 const submitGuardedByMemberMatch = !dialogAffordance.shareByEmail && !hasMatchedMember;
57 const submitDisabled =
58 creating || !peopleInput.trim() || !isActionEnabled(dialogAffordance.createUserShare) || submitGuardedByMemberMatch;
59 const submitTitle =
60 dialogAffordance.createUserShare.kind === "disabled"
61 ? dialogAffordance.createUserShare.reason
62 : submitGuardedByMemberMatch
63 ? "Members only"
64 : undefined;
65 const inputRef = useRef<HTMLInputElement>(null);
66 const suggestionsOpen = showSuggestions && filteredSuggestions.length > 0 && peopleInput.trim().length > 0;
67 
68 return (
69 <div className="mb-3">
70 <p className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-zinc-400">
71 <Users className="h-3 w-3" />
72 People
73 </p>
74 <div className="mb-2 flex items-center gap-1.5">
75 <input
76 ref={inputRef}
77 type="text"
78 placeholder={placeholder}
79 aria-label={ariaLabel}
80 value={peopleInput}
81 onChange={(e) => {
82 setPeopleInput(e.target.value);
83 openSuggestions();
84 }}
85 onFocus={openSuggestions}
86 onKeyDown={(e) => {
87 if (e.key === "Enter") {
88 void submitPeopleShare();
89 }
90 }}
91 className="min-w-0 flex-1 rounded-md border border-zinc-700 bg-zinc-800 py-1.5 pl-2 text-sm text-zinc-300 outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/30"
92 />
93 <PermissionSelect value={peoplePermission} onChange={setPeoplePermission} />
94 <Button
95 variant="secondary"
96 size="sm"
97 onClick={() => void submitPeopleShare()}
98 disabled={submitDisabled}
99 title={submitTitle}
100 className="shrink-0"
101 loading={creating}
102 >
103 Share
104 </Button>
105 </div>
106 {suggestionsOpen && (
107 <DropdownPortal
108 triggerRef={inputRef}
109 align="left"
110 widthMode="match-trigger"
111 onClose={dismissSuggestions}
112 className="max-h-32 overflow-y-auto py-1"
113 >
114 {filteredSuggestions.map((member) => (
115 <button
116 key={member.user_id}
117 onClick={() => selectMember(member)}
118 className="flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm text-zinc-300 transition-colors hover:bg-zinc-700"
119 >
120 <span className="truncate">{memberName(member)}</span>
121 {member.user?.email && member.user.name && (
122 <span className="truncate text-zinc-500">{member.user.email}</span>
123 )}
124 </button>
125 ))}
126 </DropdownPortal>
127 )}
128 {userShares.length > 0 ? (
129 <div className="space-y-1">
130 {userShares.map((share) => (
131 <ShareUserRow
132 key={share.id}
133 share={share}
134 label={granteeName(share)}
135 workspaceRole={workspaceRole}
136 online={online}
137 currentUserId={currentUserId}
138 granteeIsWorkspaceMember={members.some((member) => member.user_id === share.grantee_id)}
139 onDelete={() => void deleteShare(share.id)}
140 />
141 ))}
142 </div>
143 ) : (
144 <p className="px-2 text-sm text-zinc-400">No people shares</p>
145 )}
146 </div>
147 );
148}
149 
150export function ShareLinkSection() {
151 const { creating, dialogAffordance, workspaceRole, online, currentUserId, deleteShare } = useShareDialogShell();
152 const { linkPermission, linkShares, copiedId, setLinkPermission, createLinkShare, copyLink } = useShareLink();
153 
154 return (
155 <div className={dialogAffordance.showPeopleSection ? "border-t border-zinc-700/50 pt-3" : ""}>
156 <p className="mb-1.5 flex items-center gap-1.5 text-sm font-medium text-zinc-400">
157 <Link2 className="h-3 w-3" />
158 Link
159 </p>
160 {isActionVisible(dialogAffordance.createLinkShare) && (
161 <div className="mb-2 flex items-center gap-1.5">
162 <button
163 onClick={() => void createLinkShare()}
164 disabled={creating || !isActionEnabled(dialogAffordance.createLinkShare)}
165 title={
166 dialogAffordance.createLinkShare.kind === "disabled" ? dialogAffordance.createLinkShare.reason : undefined
167 }
168 className="flex flex-1 items-center gap-2 rounded-md border border-dashed border-zinc-700 px-2 py-1.5 text-sm text-zinc-400 transition-colors hover:border-zinc-600 hover:bg-zinc-700/40 hover:text-zinc-100 disabled:opacity-50"
169 >
170 {creating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
171 Create link
172 </button>
173 <PermissionSelect value={linkPermission} onChange={setLinkPermission} />
174 </div>
175 )}
176 {linkShares.length > 0 ? (
177 <div className="space-y-1">
178 {linkShares.map((share) => (
179 <ShareLinkRow
180 key={share.id}
181 share={share}
182 copiedId={copiedId}
183 workspaceRole={workspaceRole}
184 online={online}
185 currentUserId={currentUserId}
186 onCopy={() => copyLink(share)}
187 onDelete={() => void deleteShare(share.id)}
188 />
189 ))}
190 </div>
191 ) : (
192 isActionVisible(dialogAffordance.createLinkShare) && (
193 <p className="px-2 text-sm text-zinc-400">No link shares</p>
194 )
195 )}
196 </div>
197 );
198}
199 
200function ShareUserRow({
201 share,
202 label,
203 workspaceRole,
204 online,
205 currentUserId,
206 granteeIsWorkspaceMember,
207 onDelete,
208}: {
209 share: PageShare;
210 label: string;
211 workspaceRole: WorkspaceRole;
212 online: boolean;
213 currentUserId: string | undefined;
214 granteeIsWorkspaceMember: boolean;
215 onDelete: () => void;
216}) {
217 const rowAffordance = deriveShareDialogRowAffordance({
218 workspaceRole,
219 online,
220 currentUserId,
221 share,
222 granteeIsWorkspaceMember,
223 });
224 
225 return (
226 <div className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-zinc-700/40">
227 <div className="flex items-center gap-2 overflow-hidden">
228 <Users className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
229 <span className="truncate text-sm text-zinc-300">{label}</span>
230 <span className="rounded bg-zinc-800 px-1.5 py-0.5 text-xs text-zinc-500">{share.permission}</span>
231 </div>
232 {isActionVisible(rowAffordance.revoke) && (
233 <Button
234 variant="ghost"
235 size="sm"
236 iconOnly
237 onClick={onDelete}
238 disabled={!isActionEnabled(rowAffordance.revoke)}
239 title={rowAffordance.revoke.kind === "disabled" ? rowAffordance.revoke.reason : undefined}
240 aria-label="Remove share"
241 icon={<Trash2 className="h-3.5 w-3.5" />}
242 className="hover:text-red-400"
243 />
244 )}
245 </div>
246 );
247}
248 
249function ShareLinkRow({
250 share,
251 copiedId,
252 workspaceRole,
253 online,
254 currentUserId,
255 onCopy,
256 onDelete,
257}: {
258 share: PageShare;
259 copiedId: string | null;
260 workspaceRole: WorkspaceRole;
261 online: boolean;
262 currentUserId: string | undefined;
263 onCopy: () => void;
264 onDelete: () => void;
265}) {
266 const rowAffordance = deriveShareDialogRowAffordance({
267 workspaceRole,
268 online,
269 currentUserId,
270 share,
271 granteeIsWorkspaceMember: false,
272 });
273 
274 return (
275 <div className="flex items-center justify-between rounded-md px-2 py-1.5 hover:bg-zinc-700/40">
276 <div className="flex items-center gap-2 overflow-hidden">
277 <Link2 className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
278 <span className="truncate text-sm text-zinc-300">{share.permission} link</span>
279 </div>
280 <div className="flex items-center gap-1">
281 {isActionVisible(rowAffordance.copyLink) && (
282 <Button
283 variant="ghost"
284 size="sm"
285 iconOnly
286 onClick={onCopy}
287 aria-label="Copy link"
288 icon={
289 copiedId === share.id ? (
290 <Check className="h-3.5 w-3.5 text-green-400" />
291 ) : (
292 <Copy className="h-3.5 w-3.5" />
293 )
294 }
295 />
296 )}
297 {isActionVisible(rowAffordance.revoke) && (
298 <Button
299 variant="ghost"
300 size="sm"
301 iconOnly
302 onClick={onDelete}
303 disabled={!isActionEnabled(rowAffordance.revoke)}
304 title={rowAffordance.revoke.kind === "disabled" ? rowAffordance.revoke.reason : undefined}
305 aria-label="Remove link"
306 icon={<Trash2 className="h-3.5 w-3.5" />}
307 className="hover:text-red-400"
308 />
309 )}
310 </div>
311 </div>
312 );
313}