this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Split workspace-settings route into lazy variant for code-splitting

+641 -634
+5 -1
apps/web/src/routeTree.gen.ts
··· 198 198 id: '/workspace-settings/$rkey', 199 199 path: '/workspace-settings/$rkey', 200 200 getParentRoute: () => CabinetRouteRoute, 201 - } as any) 201 + } as any).lazy(() => 202 + import('./routes/cabinet/workspace-settings/$rkey.lazy').then( 203 + (d) => d.Route, 204 + ), 205 + ) 202 206 const CabinetFilesSplatRoute = CabinetFilesSplatRouteImport.update({ 203 207 id: '/$', 204 208 path: '/$',
+634
apps/web/src/routes/cabinet/workspace-settings/$rkey.lazy.tsx
··· 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 + import { Link, useNavigate, createLazyFileRoute } from "@tanstack/react-router"; 3 + import { 4 + ArrowLeftIcon, 5 + UsersIcon, 6 + CrownIcon, 7 + PencilSimpleIcon, 8 + EyeIcon, 9 + UserMinusIcon, 10 + UserPlusIcon, 11 + SignOutIcon, 12 + TrashIcon, 13 + } from "@phosphor-icons/react"; 14 + import type { WorkspaceMember } from "@opake/sdk"; 15 + import { DestructiveConfirmation } from "@/components/DestructiveConfirmation"; 16 + import { PanelShell } from "@/components/cabinet/PanelShell"; 17 + import { OpakeLogoSquares } from "@/components/OpakeLogoSquares"; 18 + import { Breadcrumbs, BreadcrumbActive } from "@/components/cabinet/Breadcrumbs"; 19 + import { AddMemberDialog, type AddMemberDialogHandle } from "@/components/cabinet/AddMemberDialog"; 20 + import { getOpake, useAuthStore } from "@/stores/auth"; 21 + import { useWorkspaceStore } from "@/stores/workspace"; 22 + import { toastError, toastSuccess } from "@/stores/toast"; 23 + import { rkeyFromUri } from "@/lib/atUri"; 24 + import { resolveMemberProfile, type MemberProfile } from "@/lib/profileResolution"; 25 + import { toMemberEntry, type KeyringMemberEntry, type WorkspaceRole } from "@/lib/workspaceSchemas"; 26 + import { loading } from "@/stores/app"; 27 + 28 + // --------------------------------------------------------------------------- 29 + // Constants 30 + // --------------------------------------------------------------------------- 31 + 32 + const ROLE_ICON: Readonly<Record<WorkspaceRole, typeof CrownIcon>> = { 33 + manager: CrownIcon, 34 + editor: PencilSimpleIcon, 35 + viewer: EyeIcon, 36 + }; 37 + 38 + const ROLE_OPTIONS: readonly WorkspaceRole[] = ["manager", "editor", "viewer"]; 39 + 40 + // --------------------------------------------------------------------------- 41 + // Settings page 42 + // --------------------------------------------------------------------------- 43 + 44 + function WorkspaceSettingsPage() { 45 + const { rkey } = Route.useParams(); 46 + const navigate = useNavigate(); 47 + const addMemberDialogRef = useRef<AddMemberDialogHandle>(null); 48 + const iconInputRef = useRef<HTMLInputElement>(null); 49 + 50 + // Current session 51 + const session = useAuthStore((s) => s.session); 52 + const myDid = session.status === "active" ? session.did : null; 53 + 54 + // Workspace metadata from the sidebar store (loaded on cabinet mount) 55 + const workspace = useWorkspaceStore((s) => 56 + Object.values(s.workspaces).find((w) => rkeyFromUri(w.uri) === rkey), 57 + ); 58 + const keyringUri = workspace?.uri ?? null; 59 + 60 + // Members + key material — fetched on-demand for this page 61 + const [rawMembers, setRawMembers] = useState<readonly WorkspaceMember[]>([]); 62 + const [groupKey, setGroupKey] = useState<Uint8Array | null>(null); 63 + const [loadError, setLoadError] = useState<string | null>(null); 64 + const members: readonly KeyringMemberEntry[] = useMemo( 65 + () => rawMembers.map(toMemberEntry), 66 + [rawMembers], 67 + ); 68 + 69 + // Editable metadata — override pattern lets us track "dirty" without 70 + // wiping the seed when the underlying record re-renders 71 + const seedName = workspace?.name ?? ""; 72 + const seedDescription = workspace?.description ?? ""; 73 + const seedIcon = workspace?.icon ?? null; 74 + const [nameOverride, setNameOverride] = useState<string | null>(null); 75 + const [descriptionOverride, setDescriptionOverride] = useState<string | null>(null); 76 + const [iconOverride, setIconOverride] = useState<string | null>(null); 77 + const name = nameOverride ?? seedName; 78 + const description = descriptionOverride ?? seedDescription; 79 + const icon = iconOverride ?? seedIcon; 80 + const metaDirty = nameOverride !== null || descriptionOverride !== null || iconOverride !== null; 81 + 82 + // Profile resolution for member rows 83 + const [profiles, setProfiles] = useState<Readonly<Record<string, MemberProfile | null>>>({}); 84 + 85 + // Confirm state (two-click for remove + leave, phrase-typing for delete) 86 + const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null); 87 + const [confirmingLeave, setConfirmingLeave] = useState(false); 88 + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); 89 + 90 + const role = useMemo(() => members.find((m) => m.did === myDid)?.role ?? null, [members, myDid]); 91 + const isManager = role === "manager"; 92 + const isOwner = myDid !== null && myDid === workspace?.ownerDid; 93 + const canManage = isOwner || isManager; 94 + 95 + // ----------------------------------------------------------------- 96 + // Loaders 97 + // ----------------------------------------------------------------- 98 + 99 + const reloadMembersAndKey = useCallback(async (uri: string) => { 100 + const opake = getOpake(); 101 + const ms = await opake.listWorkspaceMembers(uri); 102 + setRawMembers(ms); 103 + // Re-unwrap — owner rotations change the key, additions don't 104 + const key = await opake.unwrapGroupKey(ms); 105 + setGroupKey(key); 106 + }, []); 107 + 108 + // Refresh after a member mutation. Pulls the fresh member list + key 109 + // from the PDS (no appview lag on that path), then optimistically 110 + // patches the sidebar's member count. We deliberately skip calling 111 + // `loadWorkspaces()` because it would re-fetch via appview and clobber 112 + // any optimistic metadata patch from a prior save. The visibility 113 + // listener reconciles the full workspace record eventually. 114 + const refreshAfterMemberChange = useCallback( 115 + async (uri: string, memberCountDelta: number) => { 116 + await reloadMembersAndKey(uri); 117 + useWorkspaceStore.setState((draft) => { 118 + if (!(uri in draft.workspaces)) return; 119 + const w = draft.workspaces[uri]; 120 + draft.workspaces[uri] = { 121 + ...w, 122 + memberCount: Math.max(0, w.memberCount + memberCountDelta), 123 + }; 124 + }); 125 + }, 126 + [reloadMembersAndKey], 127 + ); 128 + 129 + // Initial load 130 + useEffect(() => { 131 + if (!keyringUri) return; 132 + const uri = keyringUri; 133 + const done = loading("workspace-settings-load"); 134 + (async () => { 135 + try { 136 + await reloadMembersAndKey(uri); 137 + setLoadError(null); 138 + } catch (err) { 139 + setLoadError(err instanceof Error ? err.message : "Failed to load members"); 140 + } finally { 141 + done(); 142 + } 143 + })().catch((err: unknown) => { 144 + console.error("[workspace-settings] load failed:", err); 145 + }); 146 + }, [keyringUri, reloadMembersAndKey]); 147 + 148 + // Profile resolution 149 + useEffect(() => { 150 + if (members.length === 0) return; 151 + const unresolved = members.map((m) => m.did).filter((did) => !(did in profiles)); 152 + if (unresolved.length === 0) return; 153 + void Promise.all( 154 + unresolved.map(async (did) => { 155 + const profile = await resolveMemberProfile(did); 156 + setProfiles((prev) => ({ ...prev, [did]: profile })); 157 + }), 158 + ); 159 + }, [members, profiles]); 160 + 161 + // ----------------------------------------------------------------- 162 + // Handlers 163 + // ----------------------------------------------------------------- 164 + 165 + const handleSaveMetadata = useCallback(() => { 166 + if (!keyringUri || !groupKey || !name.trim()) return; 167 + const uri = keyringUri; 168 + const done = loading("save-workspace-metadata"); 169 + (async () => { 170 + try { 171 + const nextName = nameOverride != null ? name.trim() : undefined; 172 + const nextDesc = descriptionOverride != null ? description.trim() : undefined; 173 + const nextIcon = iconOverride ?? undefined; 174 + await getOpake().updateWorkspaceMetadata(uri, groupKey, { 175 + name: nextName, 176 + description: nextDesc, 177 + icon: nextIcon, 178 + }); 179 + toastSuccess("Workspace updated"); 180 + 181 + // Optimistic patch: the appview has a 4s cursor lag, so 182 + // `loadWorkspaces` would return stale data and blank the form. 183 + // Update the store locally with the known-saved values; the 184 + // visibility listener (or a future SSE keyring callback) will 185 + // eventually reconcile against the real record. 186 + useWorkspaceStore.setState((draft) => { 187 + if (!(uri in draft.workspaces)) return; 188 + const w = draft.workspaces[uri]; 189 + draft.workspaces[uri] = { 190 + ...w, 191 + name: nextName ?? w.name, 192 + description: nextDesc ?? w.description, 193 + icon: nextIcon ?? w.icon, 194 + }; 195 + }); 196 + setNameOverride(null); 197 + setDescriptionOverride(null); 198 + setIconOverride(null); 199 + } catch (err) { 200 + toastError(err instanceof Error ? err.message : "Failed to update workspace"); 201 + } finally { 202 + done(); 203 + } 204 + })().catch((err: unknown) => { 205 + console.error("[workspace-settings] save failed:", err); 206 + }); 207 + }, [keyringUri, groupKey, name, description, nameOverride, descriptionOverride, iconOverride]); 208 + 209 + const handleIconSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { 210 + const file = e.target.files?.[0]; 211 + if (!file) return; 212 + 213 + // FileReader + canvas pipeline: decode → resize to 128×128 → base64 214 + const reader = new FileReader(); 215 + // eslint-disable-next-line functional/immutable-data -- FileReader callback pattern 216 + reader.onload = () => { 217 + const img = new Image(); 218 + // eslint-disable-next-line functional/immutable-data -- Image callback pattern 219 + img.onload = () => { 220 + const canvas = document.createElement("canvas"); 221 + // eslint-disable-next-line functional/immutable-data 222 + canvas.width = 128; 223 + // eslint-disable-next-line functional/immutable-data 224 + canvas.height = 128; 225 + const ctx = canvas.getContext("2d"); 226 + if (!ctx) return; 227 + ctx.drawImage(img, 0, 0, 128, 128); 228 + const dataUrl = canvas.toDataURL("image/png"); 229 + setIconOverride(dataUrl.split(",")[1] ?? null); 230 + }; 231 + // eslint-disable-next-line functional/immutable-data -- Image src setter triggers load 232 + img.src = reader.result as string; 233 + }; 234 + reader.readAsDataURL(file); 235 + }, []); 236 + 237 + const handleRemove = useCallback( 238 + (memberDid: string) => { 239 + if (!keyringUri || !groupKey) return; 240 + if (confirmingRemove !== memberDid) { 241 + setConfirmingRemove(memberDid); 242 + return; 243 + } 244 + setConfirmingRemove(null); 245 + 246 + const uri = keyringUri; 247 + const done = loading("remove-workspace-member"); 248 + (async () => { 249 + try { 250 + const result = await getOpake().removeWorkspaceMember(uri, groupKey, memberDid); 251 + toastSuccess(result.proposed ? "Removal proposed" : "Member removed, key rotated"); 252 + await refreshAfterMemberChange(uri, -1); 253 + } catch (err) { 254 + toastError(err instanceof Error ? err.message : "Failed to remove member"); 255 + } finally { 256 + done(); 257 + } 258 + })().catch((err: unknown) => { 259 + console.error("[workspace-settings] remove failed:", err); 260 + }); 261 + }, 262 + [keyringUri, groupKey, confirmingRemove, refreshAfterMemberChange], 263 + ); 264 + 265 + const handleLeave = useCallback(() => { 266 + if (!keyringUri) return; 267 + if (!confirmingLeave) { 268 + setConfirmingLeave(true); 269 + return; 270 + } 271 + const uri = keyringUri; 272 + const done = loading("leave-workspace"); 273 + (async () => { 274 + try { 275 + await getOpake().leaveWorkspace(uri); 276 + toastSuccess("Left workspace"); 277 + await useWorkspaceStore.getState().loadWorkspaces(); 278 + void navigate({ to: "/cabinet/files" }); 279 + } catch (err) { 280 + toastError(err instanceof Error ? err.message : "Failed to leave workspace"); 281 + } finally { 282 + done(); 283 + } 284 + })().catch((err: unknown) => { 285 + console.error("[workspace-settings] leave failed:", err); 286 + }); 287 + }, [keyringUri, confirmingLeave, navigate]); 288 + 289 + const handleDeleteWorkspace = useCallback(() => { 290 + if (!keyringUri) return; 291 + // TODO: workspace deletion not yet implemented in core. Needs purge of 292 + // docs + directories + keyring record as a single transaction. For now 293 + // the confirmation just navigates away — no destructive action taken. 294 + toastError("Delete workspace not yet implemented"); 295 + void navigate({ to: "/cabinet/files" }); 296 + }, [keyringUri, navigate]); 297 + 298 + const handleAddMember = useCallback( 299 + (handle: string, memberRole: WorkspaceRole) => { 300 + if (!keyringUri || !groupKey) return; 301 + const uri = keyringUri; 302 + const done = loading("add-workspace-member"); 303 + (async () => { 304 + try { 305 + const opake = getOpake(); 306 + const identity = await opake.resolveIdentity(handle); 307 + await opake.addWorkspaceMember( 308 + uri, 309 + groupKey, 310 + identity.did, 311 + identity.publicKey, 312 + memberRole, 313 + ); 314 + toastSuccess(`Added ${identity.handle ?? identity.did}`); 315 + await refreshAfterMemberChange(uri, 1); 316 + } catch (err) { 317 + toastError(err instanceof Error ? err.message : "Failed to add member"); 318 + } finally { 319 + done(); 320 + } 321 + })().catch((err: unknown) => { 322 + console.error("[workspace-settings] add failed:", err); 323 + }); 324 + }, 325 + [keyringUri, groupKey, refreshAfterMemberChange], 326 + ); 327 + 328 + const handleRoleChange = useCallback( 329 + (memberDid: string, newRole: WorkspaceRole) => { 330 + if (!keyringUri) return; 331 + const uri = keyringUri; 332 + const done = loading("update-member-role"); 333 + (async () => { 334 + try { 335 + await getOpake().updateMemberRole(uri, memberDid, newRole); 336 + toastSuccess("Role updated"); 337 + await refreshAfterMemberChange(uri, 0); 338 + } catch (err) { 339 + toastError(err instanceof Error ? err.message : "Failed to update role"); 340 + } finally { 341 + done(); 342 + } 343 + })().catch((err: unknown) => { 344 + console.error("[workspace-settings] role change failed:", err); 345 + }); 346 + }, 347 + [keyringUri, refreshAfterMemberChange], 348 + ); 349 + 350 + // ----------------------------------------------------------------- 351 + // Render 352 + // ----------------------------------------------------------------- 353 + 354 + const workspaceName = workspace?.name ?? "Workspace"; 355 + 356 + const breadcrumbs = ( 357 + <Breadcrumbs> 358 + <li> 359 + <Link to="/cabinet/workspace/$rkey" params={{ rkey }} className="text-text-faint"> 360 + <UsersIcon size={14} className="mr-1.5 inline md:hidden" /> 361 + {workspaceName} 362 + </Link> 363 + </li> 364 + <BreadcrumbActive>Settings</BreadcrumbActive> 365 + </Breadcrumbs> 366 + ); 367 + 368 + const toolbar = ( 369 + <Link 370 + to="/cabinet/workspace/$rkey" 371 + params={{ rkey }} 372 + className="btn btn-ghost btn-sm gap-1.5 rounded-lg text-xs" 373 + > 374 + <ArrowLeftIcon size={13} /> 375 + Back 376 + </Link> 377 + ); 378 + 379 + if (!workspace) { 380 + return ( 381 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Loading…"> 382 + <div className="flex h-full items-center justify-center"> 383 + <OpakeLogoSquares size="lg" loading /> 384 + </div> 385 + </PanelShell> 386 + ); 387 + } 388 + 389 + return ( 390 + <PanelShell depth={1} breadcrumbs={breadcrumbs} toolbar={toolbar} footer="End-to-end encrypted"> 391 + <div className="mx-auto max-w-lg space-y-8 px-6 py-6"> 392 + {/* Metadata */} 393 + <section> 394 + <h2 className="text-base-content mb-3 text-sm font-semibold">Workspace</h2> 395 + <div className="space-y-3"> 396 + <div className="flex items-center gap-4"> 397 + <button 398 + onClick={() => iconInputRef.current?.click()} 399 + className="group relative shrink-0" 400 + aria-label="Change workspace icon" 401 + disabled={!canManage} 402 + > 403 + {icon ? ( 404 + <img 405 + src={`data:image/png;base64,${icon}`} 406 + alt="" 407 + className="size-14 rounded-xl object-cover" 408 + /> 409 + ) : ( 410 + <div className="bg-accent text-primary flex size-14 items-center justify-center rounded-xl text-xl font-semibold"> 411 + {name[0].toUpperCase()} 412 + </div> 413 + )} 414 + {canManage && ( 415 + <div className="bg-base-content/50 absolute inset-0 flex items-center justify-center rounded-xl opacity-0 transition-opacity group-hover:opacity-100"> 416 + <PencilSimpleIcon size={16} className="text-white" /> 417 + </div> 418 + )} 419 + </button> 420 + <input 421 + ref={iconInputRef} 422 + type="file" 423 + accept="image/*" 424 + onChange={handleIconSelected} 425 + className="sr-only" 426 + aria-hidden="true" 427 + /> 428 + <div className="flex-1"> 429 + <span className="text-ui text-base-content block">{name || "Unnamed"}</span> 430 + <span className="text-caption text-text-faint"> 431 + {description || "No description"} 432 + </span> 433 + </div> 434 + </div> 435 + <div> 436 + <label htmlFor="ws-name" className="text-caption text-text-muted mb-1 block"> 437 + Name 438 + </label> 439 + <input 440 + id="ws-name" 441 + type="text" 442 + value={name} 443 + onChange={(e) => setNameOverride(e.target.value)} 444 + disabled={!canManage} 445 + className="input input-bordered input-sm border-base-300/50 w-full rounded-lg text-xs" 446 + /> 447 + </div> 448 + <div> 449 + <label htmlFor="ws-desc" className="text-caption text-text-muted mb-1 block"> 450 + Description 451 + </label> 452 + <textarea 453 + id="ws-desc" 454 + value={description} 455 + onChange={(e) => setDescriptionOverride(e.target.value)} 456 + disabled={!canManage} 457 + className="textarea textarea-bordered textarea-sm border-base-300/50 w-full rounded-lg text-xs" 458 + rows={2} 459 + /> 460 + </div> 461 + {metaDirty && ( 462 + <button 463 + onClick={handleSaveMetadata} 464 + disabled={!name.trim() || !groupKey} 465 + className="btn btn-primary btn-sm rounded-lg text-xs" 466 + > 467 + Save changes 468 + </button> 469 + )} 470 + </div> 471 + </section> 472 + 473 + {/* Members */} 474 + <section> 475 + <div className="mb-3 flex items-center justify-between"> 476 + <h2 className="text-base-content text-sm font-semibold">Members ({members.length})</h2> 477 + {isManager && ( 478 + <button 479 + onClick={() => addMemberDialogRef.current?.show()} 480 + className="btn btn-ghost btn-xs gap-1 rounded-lg" 481 + > 482 + <UserPlusIcon size={13} /> 483 + Add 484 + </button> 485 + )} 486 + </div> 487 + {loadError && <p className="text-caption text-error mb-2">{loadError}</p>} 488 + <ul className="space-y-1" aria-label="Member list"> 489 + {members.map((member) => ( 490 + <MemberRow 491 + key={member.did} 492 + member={member} 493 + profile={member.did in profiles ? (profiles[member.did] ?? null) : null} 494 + isMe={member.did === myDid} 495 + isManager={isManager} 496 + confirmingRemove={confirmingRemove === member.did} 497 + onRemove={() => handleRemove(member.did)} 498 + onRoleChange={(newRole) => handleRoleChange(member.did, newRole)} 499 + /> 500 + ))} 501 + </ul> 502 + </section> 503 + 504 + {/* Danger zone */} 505 + <section> 506 + <h2 className="text-error mb-3 text-sm font-semibold">Danger zone</h2> 507 + <div className="border-error/20 space-y-4 rounded-lg border p-4"> 508 + {/* Leave — non-owners only */} 509 + {!isOwner && ( 510 + <div> 511 + <p className="text-caption text-text-muted mb-2"> 512 + Leave this workspace. Your access will be revoked. 513 + </p> 514 + <button 515 + onClick={handleLeave} 516 + className="btn btn-confirm-danger btn-sm gap-1.5 rounded-lg text-xs" 517 + data-confirming={confirmingLeave || undefined} 518 + > 519 + <SignOutIcon size={13} /> 520 + {confirmingLeave ? "Click again to confirm" : "Leave workspace"} 521 + </button> 522 + </div> 523 + )} 524 + 525 + {/* Delete — owner only */} 526 + {isOwner && ( 527 + <div> 528 + <p className="text-caption text-text-muted mb-2"> 529 + Permanently delete this workspace and all its files. This cannot be undone. 530 + </p> 531 + {showDeleteConfirm ? ( 532 + <DestructiveConfirmation 533 + phrase={`I want to delete ${workspaceName} and all its data`} 534 + onConfirm={handleDeleteWorkspace} 535 + /> 536 + ) : ( 537 + <button 538 + onClick={() => setShowDeleteConfirm(true)} 539 + className="btn btn-ghost btn-sm gap-1.5 rounded-lg text-xs" 540 + > 541 + <TrashIcon size={13} /> 542 + Delete workspace 543 + </button> 544 + )} 545 + </div> 546 + )} 547 + </div> 548 + </section> 549 + </div> 550 + 551 + <AddMemberDialog ref={addMemberDialogRef} onConfirm={handleAddMember} /> 552 + </PanelShell> 553 + ); 554 + } 555 + 556 + // --------------------------------------------------------------------------- 557 + // Member row 558 + // --------------------------------------------------------------------------- 559 + 560 + function MemberRow({ 561 + member, 562 + profile, 563 + isMe, 564 + isManager, 565 + confirmingRemove, 566 + onRemove, 567 + onRoleChange, 568 + }: { 569 + readonly member: KeyringMemberEntry; 570 + readonly profile: MemberProfile | null; 571 + readonly isMe: boolean; 572 + readonly isManager: boolean; 573 + readonly confirmingRemove: boolean; 574 + readonly onRemove: () => void; 575 + readonly onRoleChange: (role: WorkspaceRole) => void; 576 + }) { 577 + const RoleIcon = ROLE_ICON[member.role]; 578 + const displayName = profile?.handle ?? member.did; 579 + const canRemove = isManager && !isMe; 580 + const canChangeRole = isManager && !isMe; 581 + 582 + return ( 583 + <li className="flex items-center gap-3 rounded-lg px-3 py-2.5"> 584 + {profile?.avatarUrl ? ( 585 + <img src={profile.avatarUrl} alt="" className="size-8 shrink-0 rounded-full object-cover" /> 586 + ) : ( 587 + <div className="bg-accent text-primary text-micro flex size-8 shrink-0 items-center justify-center rounded-full font-semibold"> 588 + {displayName[0].toUpperCase()} 589 + </div> 590 + )} 591 + <div className="flex min-w-0 flex-1 flex-col"> 592 + <span className="text-ui text-base-content truncate"> 593 + {displayName} 594 + {isMe && <span className="text-text-faint ml-1.5 text-[10px]">(you)</span>} 595 + </span> 596 + {canChangeRole ? ( 597 + <select 598 + value={member.role} 599 + onChange={(e) => onRoleChange(e.target.value as WorkspaceRole)} 600 + className="select select-xs text-caption text-base-content bg-base-100 w-24 rounded-lg" 601 + aria-label={`Role for ${displayName}`} 602 + > 603 + {ROLE_OPTIONS.map((r) => ( 604 + <option key={r} value={r}> 605 + {r.charAt(0).toUpperCase() + r.slice(1)} 606 + </option> 607 + ))} 608 + </select> 609 + ) : ( 610 + <span className="text-caption text-text-faint flex items-center gap-1"> 611 + <RoleIcon size={10} /> 612 + {member.role.charAt(0).toUpperCase() + member.role.slice(1)} 613 + </span> 614 + )} 615 + </div> 616 + {canRemove && ( 617 + <button 618 + onClick={onRemove} 619 + className="btn btn-confirm-danger btn-xs rounded-lg" 620 + data-confirming={confirmingRemove || undefined} 621 + title={confirmingRemove ? "Click again to confirm" : "Remove member"} 622 + aria-label={`Remove ${displayName}`} 623 + > 624 + <UserMinusIcon size={14} /> 625 + {confirmingRemove && <span className="text-[10px]">confirm?</span>} 626 + </button> 627 + )} 628 + </li> 629 + ); 630 + } 631 + 632 + export const Route = createLazyFileRoute("/cabinet/workspace-settings/$rkey")({ 633 + component: WorkspaceSettingsPage, 634 + });
+2 -633
apps/web/src/routes/cabinet/workspace-settings/$rkey.tsx
··· 1 - import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 - import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; 3 - import { 4 - ArrowLeftIcon, 5 - UsersIcon, 6 - CrownIcon, 7 - PencilSimpleIcon, 8 - EyeIcon, 9 - UserMinusIcon, 10 - UserPlusIcon, 11 - SignOutIcon, 12 - TrashIcon, 13 - } from "@phosphor-icons/react"; 14 - import type { WorkspaceMember } from "@opake/sdk"; 15 - import { DestructiveConfirmation } from "@/components/DestructiveConfirmation"; 16 - import { PanelShell } from "@/components/cabinet/PanelShell"; 17 - import { OpakeLogoSquares } from "@/components/OpakeLogoSquares"; 18 - import { Breadcrumbs, BreadcrumbActive } from "@/components/cabinet/Breadcrumbs"; 19 - import { AddMemberDialog, type AddMemberDialogHandle } from "@/components/cabinet/AddMemberDialog"; 20 - import { getOpake, useAuthStore } from "@/stores/auth"; 21 - import { useWorkspaceStore } from "@/stores/workspace"; 22 - import { toastError, toastSuccess } from "@/stores/toast"; 23 - import { rkeyFromUri } from "@/lib/atUri"; 24 - import { resolveMemberProfile, type MemberProfile } from "@/lib/profileResolution"; 25 - import { toMemberEntry, type KeyringMemberEntry, type WorkspaceRole } from "@/lib/workspaceSchemas"; 26 - import { loading } from "@/stores/app"; 27 - 28 - // --------------------------------------------------------------------------- 29 - // Constants 30 - // --------------------------------------------------------------------------- 31 - 32 - const ROLE_ICON: Readonly<Record<WorkspaceRole, typeof CrownIcon>> = { 33 - manager: CrownIcon, 34 - editor: PencilSimpleIcon, 35 - viewer: EyeIcon, 36 - }; 37 - 38 - const ROLE_OPTIONS: readonly WorkspaceRole[] = ["manager", "editor", "viewer"]; 39 - 40 - // --------------------------------------------------------------------------- 41 - // Settings page 42 - // --------------------------------------------------------------------------- 43 - 44 - function WorkspaceSettingsPage() { 45 - const { rkey } = Route.useParams(); 46 - const navigate = useNavigate(); 47 - const addMemberDialogRef = useRef<AddMemberDialogHandle>(null); 48 - const iconInputRef = useRef<HTMLInputElement>(null); 49 - 50 - // Current session 51 - const session = useAuthStore((s) => s.session); 52 - const myDid = session.status === "active" ? session.did : null; 53 - 54 - // Workspace metadata from the sidebar store (loaded on cabinet mount) 55 - const workspace = useWorkspaceStore((s) => 56 - Object.values(s.workspaces).find((w) => rkeyFromUri(w.uri) === rkey), 57 - ); 58 - const keyringUri = workspace?.uri ?? null; 59 - 60 - // Members + key material — fetched on-demand for this page 61 - const [rawMembers, setRawMembers] = useState<readonly WorkspaceMember[]>([]); 62 - const [groupKey, setGroupKey] = useState<Uint8Array | null>(null); 63 - const [loadError, setLoadError] = useState<string | null>(null); 64 - const members: readonly KeyringMemberEntry[] = useMemo( 65 - () => rawMembers.map(toMemberEntry), 66 - [rawMembers], 67 - ); 68 - 69 - // Editable metadata — override pattern lets us track "dirty" without 70 - // wiping the seed when the underlying record re-renders 71 - const seedName = workspace?.name ?? ""; 72 - const seedDescription = workspace?.description ?? ""; 73 - const seedIcon = workspace?.icon ?? null; 74 - const [nameOverride, setNameOverride] = useState<string | null>(null); 75 - const [descriptionOverride, setDescriptionOverride] = useState<string | null>(null); 76 - const [iconOverride, setIconOverride] = useState<string | null>(null); 77 - const name = nameOverride ?? seedName; 78 - const description = descriptionOverride ?? seedDescription; 79 - const icon = iconOverride ?? seedIcon; 80 - const metaDirty = nameOverride !== null || descriptionOverride !== null || iconOverride !== null; 81 - 82 - // Profile resolution for member rows 83 - const [profiles, setProfiles] = useState<Readonly<Record<string, MemberProfile | null>>>({}); 84 - 85 - // Confirm state (two-click for remove + leave, phrase-typing for delete) 86 - const [confirmingRemove, setConfirmingRemove] = useState<string | null>(null); 87 - const [confirmingLeave, setConfirmingLeave] = useState(false); 88 - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); 89 - 90 - const role = useMemo(() => members.find((m) => m.did === myDid)?.role ?? null, [members, myDid]); 91 - const isManager = role === "manager"; 92 - const isOwner = myDid !== null && myDid === workspace?.ownerDid; 93 - const canManage = isOwner || isManager; 94 - 95 - // ----------------------------------------------------------------- 96 - // Loaders 97 - // ----------------------------------------------------------------- 98 - 99 - const reloadMembersAndKey = useCallback(async (uri: string) => { 100 - const opake = getOpake(); 101 - const ms = await opake.listWorkspaceMembers(uri); 102 - setRawMembers(ms); 103 - // Re-unwrap — owner rotations change the key, additions don't 104 - const key = await opake.unwrapGroupKey(ms); 105 - setGroupKey(key); 106 - }, []); 107 - 108 - // Refresh after a member mutation. Pulls the fresh member list + key 109 - // from the PDS (no appview lag on that path), then optimistically 110 - // patches the sidebar's member count. We deliberately skip calling 111 - // `loadWorkspaces()` because it would re-fetch via appview and clobber 112 - // any optimistic metadata patch from a prior save. The visibility 113 - // listener reconciles the full workspace record eventually. 114 - const refreshAfterMemberChange = useCallback( 115 - async (uri: string, memberCountDelta: number) => { 116 - await reloadMembersAndKey(uri); 117 - useWorkspaceStore.setState((draft) => { 118 - if (!(uri in draft.workspaces)) return; 119 - const w = draft.workspaces[uri]; 120 - draft.workspaces[uri] = { 121 - ...w, 122 - memberCount: Math.max(0, w.memberCount + memberCountDelta), 123 - }; 124 - }); 125 - }, 126 - [reloadMembersAndKey], 127 - ); 128 - 129 - // Initial load 130 - useEffect(() => { 131 - if (!keyringUri) return; 132 - const uri = keyringUri; 133 - const done = loading("workspace-settings-load"); 134 - (async () => { 135 - try { 136 - await reloadMembersAndKey(uri); 137 - setLoadError(null); 138 - } catch (err) { 139 - setLoadError(err instanceof Error ? err.message : "Failed to load members"); 140 - } finally { 141 - done(); 142 - } 143 - })().catch((err: unknown) => { 144 - console.error("[workspace-settings] load failed:", err); 145 - }); 146 - }, [keyringUri, reloadMembersAndKey]); 147 - 148 - // Profile resolution 149 - useEffect(() => { 150 - if (members.length === 0) return; 151 - const unresolved = members.map((m) => m.did).filter((did) => !(did in profiles)); 152 - if (unresolved.length === 0) return; 153 - void Promise.all( 154 - unresolved.map(async (did) => { 155 - const profile = await resolveMemberProfile(did); 156 - setProfiles((prev) => ({ ...prev, [did]: profile })); 157 - }), 158 - ); 159 - }, [members, profiles]); 160 - 161 - // ----------------------------------------------------------------- 162 - // Handlers 163 - // ----------------------------------------------------------------- 164 - 165 - const handleSaveMetadata = useCallback(() => { 166 - if (!keyringUri || !groupKey || !name.trim()) return; 167 - const uri = keyringUri; 168 - const done = loading("save-workspace-metadata"); 169 - (async () => { 170 - try { 171 - const nextName = nameOverride != null ? name.trim() : undefined; 172 - const nextDesc = descriptionOverride != null ? description.trim() : undefined; 173 - const nextIcon = iconOverride ?? undefined; 174 - await getOpake().updateWorkspaceMetadata(uri, groupKey, { 175 - name: nextName, 176 - description: nextDesc, 177 - icon: nextIcon, 178 - }); 179 - toastSuccess("Workspace updated"); 180 - 181 - // Optimistic patch: the appview has a 4s cursor lag, so 182 - // `loadWorkspaces` would return stale data and blank the form. 183 - // Update the store locally with the known-saved values; the 184 - // visibility listener (or a future SSE keyring callback) will 185 - // eventually reconcile against the real record. 186 - useWorkspaceStore.setState((draft) => { 187 - if (!(uri in draft.workspaces)) return; 188 - const w = draft.workspaces[uri]; 189 - draft.workspaces[uri] = { 190 - ...w, 191 - name: nextName ?? w.name, 192 - description: nextDesc ?? w.description, 193 - icon: nextIcon ?? w.icon, 194 - }; 195 - }); 196 - setNameOverride(null); 197 - setDescriptionOverride(null); 198 - setIconOverride(null); 199 - } catch (err) { 200 - toastError(err instanceof Error ? err.message : "Failed to update workspace"); 201 - } finally { 202 - done(); 203 - } 204 - })().catch((err: unknown) => { 205 - console.error("[workspace-settings] save failed:", err); 206 - }); 207 - }, [keyringUri, groupKey, name, description, nameOverride, descriptionOverride, iconOverride]); 208 - 209 - const handleIconSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { 210 - const file = e.target.files?.[0]; 211 - if (!file) return; 212 - 213 - // FileReader + canvas pipeline: decode → resize to 128×128 → base64 214 - const reader = new FileReader(); 215 - // eslint-disable-next-line functional/immutable-data -- FileReader callback pattern 216 - reader.onload = () => { 217 - const img = new Image(); 218 - // eslint-disable-next-line functional/immutable-data -- Image callback pattern 219 - img.onload = () => { 220 - const canvas = document.createElement("canvas"); 221 - // eslint-disable-next-line functional/immutable-data 222 - canvas.width = 128; 223 - // eslint-disable-next-line functional/immutable-data 224 - canvas.height = 128; 225 - const ctx = canvas.getContext("2d"); 226 - if (!ctx) return; 227 - ctx.drawImage(img, 0, 0, 128, 128); 228 - const dataUrl = canvas.toDataURL("image/png"); 229 - setIconOverride(dataUrl.split(",")[1] ?? null); 230 - }; 231 - // eslint-disable-next-line functional/immutable-data -- Image src setter triggers load 232 - img.src = reader.result as string; 233 - }; 234 - reader.readAsDataURL(file); 235 - }, []); 1 + import { createFileRoute } from "@tanstack/react-router"; 236 2 237 - const handleRemove = useCallback( 238 - (memberDid: string) => { 239 - if (!keyringUri || !groupKey) return; 240 - if (confirmingRemove !== memberDid) { 241 - setConfirmingRemove(memberDid); 242 - return; 243 - } 244 - setConfirmingRemove(null); 245 - 246 - const uri = keyringUri; 247 - const done = loading("remove-workspace-member"); 248 - (async () => { 249 - try { 250 - const result = await getOpake().removeWorkspaceMember(uri, groupKey, memberDid); 251 - toastSuccess(result.proposed ? "Removal proposed" : "Member removed, key rotated"); 252 - await refreshAfterMemberChange(uri, -1); 253 - } catch (err) { 254 - toastError(err instanceof Error ? err.message : "Failed to remove member"); 255 - } finally { 256 - done(); 257 - } 258 - })().catch((err: unknown) => { 259 - console.error("[workspace-settings] remove failed:", err); 260 - }); 261 - }, 262 - [keyringUri, groupKey, confirmingRemove, refreshAfterMemberChange], 263 - ); 264 - 265 - const handleLeave = useCallback(() => { 266 - if (!keyringUri) return; 267 - if (!confirmingLeave) { 268 - setConfirmingLeave(true); 269 - return; 270 - } 271 - const uri = keyringUri; 272 - const done = loading("leave-workspace"); 273 - (async () => { 274 - try { 275 - await getOpake().leaveWorkspace(uri); 276 - toastSuccess("Left workspace"); 277 - await useWorkspaceStore.getState().loadWorkspaces(); 278 - void navigate({ to: "/cabinet/files" }); 279 - } catch (err) { 280 - toastError(err instanceof Error ? err.message : "Failed to leave workspace"); 281 - } finally { 282 - done(); 283 - } 284 - })().catch((err: unknown) => { 285 - console.error("[workspace-settings] leave failed:", err); 286 - }); 287 - }, [keyringUri, confirmingLeave, navigate]); 288 - 289 - const handleDeleteWorkspace = useCallback(() => { 290 - if (!keyringUri) return; 291 - // TODO: workspace deletion not yet implemented in core. Needs purge of 292 - // docs + directories + keyring record as a single transaction. For now 293 - // the confirmation just navigates away — no destructive action taken. 294 - toastError("Delete workspace not yet implemented"); 295 - void navigate({ to: "/cabinet/files" }); 296 - }, [keyringUri, navigate]); 297 - 298 - const handleAddMember = useCallback( 299 - (handle: string, memberRole: WorkspaceRole) => { 300 - if (!keyringUri || !groupKey) return; 301 - const uri = keyringUri; 302 - const done = loading("add-workspace-member"); 303 - (async () => { 304 - try { 305 - const opake = getOpake(); 306 - const identity = await opake.resolveIdentity(handle); 307 - await opake.addWorkspaceMember( 308 - uri, 309 - groupKey, 310 - identity.did, 311 - identity.publicKey, 312 - memberRole, 313 - ); 314 - toastSuccess(`Added ${identity.handle ?? identity.did}`); 315 - await refreshAfterMemberChange(uri, 1); 316 - } catch (err) { 317 - toastError(err instanceof Error ? err.message : "Failed to add member"); 318 - } finally { 319 - done(); 320 - } 321 - })().catch((err: unknown) => { 322 - console.error("[workspace-settings] add failed:", err); 323 - }); 324 - }, 325 - [keyringUri, groupKey, refreshAfterMemberChange], 326 - ); 327 - 328 - const handleRoleChange = useCallback( 329 - (memberDid: string, newRole: WorkspaceRole) => { 330 - if (!keyringUri) return; 331 - const uri = keyringUri; 332 - const done = loading("update-member-role"); 333 - (async () => { 334 - try { 335 - await getOpake().updateMemberRole(uri, memberDid, newRole); 336 - toastSuccess("Role updated"); 337 - await refreshAfterMemberChange(uri, 0); 338 - } catch (err) { 339 - toastError(err instanceof Error ? err.message : "Failed to update role"); 340 - } finally { 341 - done(); 342 - } 343 - })().catch((err: unknown) => { 344 - console.error("[workspace-settings] role change failed:", err); 345 - }); 346 - }, 347 - [keyringUri, refreshAfterMemberChange], 348 - ); 349 - 350 - // ----------------------------------------------------------------- 351 - // Render 352 - // ----------------------------------------------------------------- 353 - 354 - const workspaceName = workspace?.name ?? "Workspace"; 355 - 356 - const breadcrumbs = ( 357 - <Breadcrumbs> 358 - <li> 359 - <Link to="/cabinet/workspace/$rkey" params={{ rkey }} className="text-text-faint"> 360 - <UsersIcon size={14} className="mr-1.5 inline md:hidden" /> 361 - {workspaceName} 362 - </Link> 363 - </li> 364 - <BreadcrumbActive>Settings</BreadcrumbActive> 365 - </Breadcrumbs> 366 - ); 367 - 368 - const toolbar = ( 369 - <Link 370 - to="/cabinet/workspace/$rkey" 371 - params={{ rkey }} 372 - className="btn btn-ghost btn-sm gap-1.5 rounded-lg text-xs" 373 - > 374 - <ArrowLeftIcon size={13} /> 375 - Back 376 - </Link> 377 - ); 378 - 379 - if (!workspace) { 380 - return ( 381 - <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Loading…"> 382 - <div className="flex h-full items-center justify-center"> 383 - <OpakeLogoSquares size="lg" loading /> 384 - </div> 385 - </PanelShell> 386 - ); 387 - } 388 - 389 - return ( 390 - <PanelShell depth={1} breadcrumbs={breadcrumbs} toolbar={toolbar} footer="End-to-end encrypted"> 391 - <div className="mx-auto max-w-lg space-y-8 px-6 py-6"> 392 - {/* Metadata */} 393 - <section> 394 - <h2 className="text-base-content mb-3 text-sm font-semibold">Workspace</h2> 395 - <div className="space-y-3"> 396 - <div className="flex items-center gap-4"> 397 - <button 398 - onClick={() => iconInputRef.current?.click()} 399 - className="group relative shrink-0" 400 - aria-label="Change workspace icon" 401 - disabled={!canManage} 402 - > 403 - {icon ? ( 404 - <img 405 - src={`data:image/png;base64,${icon}`} 406 - alt="" 407 - className="size-14 rounded-xl object-cover" 408 - /> 409 - ) : ( 410 - <div className="bg-accent text-primary flex size-14 items-center justify-center rounded-xl text-xl font-semibold"> 411 - {name[0].toUpperCase()} 412 - </div> 413 - )} 414 - {canManage && ( 415 - <div className="bg-base-content/50 absolute inset-0 flex items-center justify-center rounded-xl opacity-0 transition-opacity group-hover:opacity-100"> 416 - <PencilSimpleIcon size={16} className="text-white" /> 417 - </div> 418 - )} 419 - </button> 420 - <input 421 - ref={iconInputRef} 422 - type="file" 423 - accept="image/*" 424 - onChange={handleIconSelected} 425 - className="sr-only" 426 - aria-hidden="true" 427 - /> 428 - <div className="flex-1"> 429 - <span className="text-ui text-base-content block">{name || "Unnamed"}</span> 430 - <span className="text-caption text-text-faint"> 431 - {description || "No description"} 432 - </span> 433 - </div> 434 - </div> 435 - <div> 436 - <label htmlFor="ws-name" className="text-caption text-text-muted mb-1 block"> 437 - Name 438 - </label> 439 - <input 440 - id="ws-name" 441 - type="text" 442 - value={name} 443 - onChange={(e) => setNameOverride(e.target.value)} 444 - disabled={!canManage} 445 - className="input input-bordered input-sm border-base-300/50 w-full rounded-lg text-xs" 446 - /> 447 - </div> 448 - <div> 449 - <label htmlFor="ws-desc" className="text-caption text-text-muted mb-1 block"> 450 - Description 451 - </label> 452 - <textarea 453 - id="ws-desc" 454 - value={description} 455 - onChange={(e) => setDescriptionOverride(e.target.value)} 456 - disabled={!canManage} 457 - className="textarea textarea-bordered textarea-sm border-base-300/50 w-full rounded-lg text-xs" 458 - rows={2} 459 - /> 460 - </div> 461 - {metaDirty && ( 462 - <button 463 - onClick={handleSaveMetadata} 464 - disabled={!name.trim() || !groupKey} 465 - className="btn btn-primary btn-sm rounded-lg text-xs" 466 - > 467 - Save changes 468 - </button> 469 - )} 470 - </div> 471 - </section> 472 - 473 - {/* Members */} 474 - <section> 475 - <div className="mb-3 flex items-center justify-between"> 476 - <h2 className="text-base-content text-sm font-semibold">Members ({members.length})</h2> 477 - {isManager && ( 478 - <button 479 - onClick={() => addMemberDialogRef.current?.show()} 480 - className="btn btn-ghost btn-xs gap-1 rounded-lg" 481 - > 482 - <UserPlusIcon size={13} /> 483 - Add 484 - </button> 485 - )} 486 - </div> 487 - {loadError && <p className="text-caption text-error mb-2">{loadError}</p>} 488 - <ul className="space-y-1" aria-label="Member list"> 489 - {members.map((member) => ( 490 - <MemberRow 491 - key={member.did} 492 - member={member} 493 - profile={member.did in profiles ? (profiles[member.did] ?? null) : null} 494 - isMe={member.did === myDid} 495 - isManager={isManager} 496 - confirmingRemove={confirmingRemove === member.did} 497 - onRemove={() => handleRemove(member.did)} 498 - onRoleChange={(newRole) => handleRoleChange(member.did, newRole)} 499 - /> 500 - ))} 501 - </ul> 502 - </section> 503 - 504 - {/* Danger zone */} 505 - <section> 506 - <h2 className="text-error mb-3 text-sm font-semibold">Danger zone</h2> 507 - <div className="border-error/20 space-y-4 rounded-lg border p-4"> 508 - {/* Leave — non-owners only */} 509 - {!isOwner && ( 510 - <div> 511 - <p className="text-caption text-text-muted mb-2"> 512 - Leave this workspace. Your access will be revoked. 513 - </p> 514 - <button 515 - onClick={handleLeave} 516 - className="btn btn-confirm-danger btn-sm gap-1.5 rounded-lg text-xs" 517 - data-confirming={confirmingLeave || undefined} 518 - > 519 - <SignOutIcon size={13} /> 520 - {confirmingLeave ? "Click again to confirm" : "Leave workspace"} 521 - </button> 522 - </div> 523 - )} 524 - 525 - {/* Delete — owner only */} 526 - {isOwner && ( 527 - <div> 528 - <p className="text-caption text-text-muted mb-2"> 529 - Permanently delete this workspace and all its files. This cannot be undone. 530 - </p> 531 - {showDeleteConfirm ? ( 532 - <DestructiveConfirmation 533 - phrase={`I want to delete ${workspaceName} and all its data`} 534 - onConfirm={handleDeleteWorkspace} 535 - /> 536 - ) : ( 537 - <button 538 - onClick={() => setShowDeleteConfirm(true)} 539 - className="btn btn-ghost btn-sm gap-1.5 rounded-lg text-xs" 540 - > 541 - <TrashIcon size={13} /> 542 - Delete workspace 543 - </button> 544 - )} 545 - </div> 546 - )} 547 - </div> 548 - </section> 549 - </div> 550 - 551 - <AddMemberDialog ref={addMemberDialogRef} onConfirm={handleAddMember} /> 552 - </PanelShell> 553 - ); 554 - } 555 - 556 - // --------------------------------------------------------------------------- 557 - // Member row 558 - // --------------------------------------------------------------------------- 559 - 560 - function MemberRow({ 561 - member, 562 - profile, 563 - isMe, 564 - isManager, 565 - confirmingRemove, 566 - onRemove, 567 - onRoleChange, 568 - }: { 569 - readonly member: KeyringMemberEntry; 570 - readonly profile: MemberProfile | null; 571 - readonly isMe: boolean; 572 - readonly isManager: boolean; 573 - readonly confirmingRemove: boolean; 574 - readonly onRemove: () => void; 575 - readonly onRoleChange: (role: WorkspaceRole) => void; 576 - }) { 577 - const RoleIcon = ROLE_ICON[member.role]; 578 - const displayName = profile?.handle ?? member.did; 579 - const canRemove = isManager && !isMe; 580 - const canChangeRole = isManager && !isMe; 581 - 582 - return ( 583 - <li className="flex items-center gap-3 rounded-lg px-3 py-2.5"> 584 - {profile?.avatarUrl ? ( 585 - <img src={profile.avatarUrl} alt="" className="size-8 shrink-0 rounded-full object-cover" /> 586 - ) : ( 587 - <div className="bg-accent text-primary text-micro flex size-8 shrink-0 items-center justify-center rounded-full font-semibold"> 588 - {displayName[0].toUpperCase()} 589 - </div> 590 - )} 591 - <div className="flex min-w-0 flex-1 flex-col"> 592 - <span className="text-ui text-base-content truncate"> 593 - {displayName} 594 - {isMe && <span className="text-text-faint ml-1.5 text-[10px]">(you)</span>} 595 - </span> 596 - {canChangeRole ? ( 597 - <select 598 - value={member.role} 599 - onChange={(e) => onRoleChange(e.target.value as WorkspaceRole)} 600 - className="select select-xs text-caption text-base-content bg-base-100 w-24 rounded-lg" 601 - aria-label={`Role for ${displayName}`} 602 - > 603 - {ROLE_OPTIONS.map((r) => ( 604 - <option key={r} value={r}> 605 - {r.charAt(0).toUpperCase() + r.slice(1)} 606 - </option> 607 - ))} 608 - </select> 609 - ) : ( 610 - <span className="text-caption text-text-faint flex items-center gap-1"> 611 - <RoleIcon size={10} /> 612 - {member.role.charAt(0).toUpperCase() + member.role.slice(1)} 613 - </span> 614 - )} 615 - </div> 616 - {canRemove && ( 617 - <button 618 - onClick={onRemove} 619 - className="btn btn-confirm-danger btn-xs rounded-lg" 620 - data-confirming={confirmingRemove || undefined} 621 - title={confirmingRemove ? "Click again to confirm" : "Remove member"} 622 - aria-label={`Remove ${displayName}`} 623 - > 624 - <UserMinusIcon size={14} /> 625 - {confirmingRemove && <span className="text-[10px]">confirm?</span>} 626 - </button> 627 - )} 628 - </li> 629 - ); 630 - } 631 - 632 - export const Route = createFileRoute("/cabinet/workspace-settings/$rkey")({ 633 - component: WorkspaceSettingsPage, 634 - }); 3 + export const Route = createFileRoute("/cabinet/workspace-settings/$rkey")({});