diff --git a/app/api/liveblocks-auth/route.ts b/app/api/liveblocks-auth/route.ts index a4fc585..5d640f8 100644 --- a/app/api/liveblocks-auth/route.ts +++ b/app/api/liveblocks-auth/route.ts @@ -64,11 +64,12 @@ export async function POST(request: Request) { const avatar = clerkUser?.avatarUrl ?? ""; const color = getUserColor(currentUser.userId); - // Ensure the room exists. We use ID tokens, so the room must have at - // least one access entry — `defaultAccesses: []` would lock everyone - // out, and `["room:write"]` would let any token holder join. - // Project membership is enforced by step 2 above, so we keep the room - // private (empty defaults) and rely on per-user access. + // Ensure the room exists, then grant this user write access. + // getOrCreateRoom only applies usersAccesses on *create* — if the owner + // already created the room, a collaborator would still be locked out + // (Liveblocks 4001 / "You have no access to this room"). Always update + // after create-or-get so every verified member receives room:write. + // Project membership is enforced above; rooms stay private by default. try { await liveblocks.getOrCreateRoom(roomId, { defaultAccesses: [], @@ -76,6 +77,11 @@ export async function POST(request: Request) { [currentUser.userId]: ["room:write"], }, }); + await liveblocks.updateRoom(roomId, { + usersAccesses: { + [currentUser.userId]: ["room:write"], + }, + }); } catch (error) { console.error("Failed to ensure Liveblocks room exists", error); return NextResponse.json( diff --git a/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts b/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts index 211ca0d..c500658 100644 --- a/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts +++ b/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts @@ -38,7 +38,7 @@ export async function DELETE( // Find the collaborator const collaborator = await prisma.projectCollaborator.findUnique({ where: { id: collaboratorId }, - select: { id: true, projectId: true }, + select: { id: true, projectId: true, email: true }, }); if (!collaborator || collaborator.projectId !== projectId) { @@ -49,5 +49,29 @@ export async function DELETE( where: { id: collaboratorId }, }); + // Revoke Liveblocks room access for the removed collaborator when we + // can resolve their Clerk user ID. Best-effort — DB membership is source + // of truth and auth will refuse tokens without a collaborator row. + try { + const { clerkClient } = await import("@clerk/nextjs/server"); + const { liveblocks } = await import("@/lib/liveblocks"); + const clerk = await clerkClient(); + const list = await clerk.users.getUserList({ + emailAddress: [collaborator.email.toLowerCase()], + limit: 1, + }); + const removedId = (list as unknown as { data: { id: string }[] }).data?.[0] + ?.id; + if (removedId) { + await liveblocks.updateRoom(project.id, { + usersAccesses: { + [removedId]: null, + }, + }); + } + } catch (error) { + console.error("Failed to revoke Liveblocks access on remove", error); + } + return new NextResponse(null, { status: 204 }); } \ No newline at end of file diff --git a/app/api/projects/[projectId]/collaborators/route.ts b/app/api/projects/[projectId]/collaborators/route.ts index 48404c4..ff76712 100644 --- a/app/api/projects/[projectId]/collaborators/route.ts +++ b/app/api/projects/[projectId]/collaborators/route.ts @@ -50,7 +50,11 @@ export async function GET( const primaryEmail = user.emailAddresses.find( (e) => e.id === user.primaryEmailAddressId ); - const userEmail = primaryEmail?.emailAddress ?? ""; + const userEmail = ( + primaryEmail?.emailAddress ?? + user.emailAddresses[0]?.emailAddress ?? + "" + ).toLowerCase(); const actualCollaborator = await prisma.projectCollaborator.findUnique({ where: { @@ -123,14 +127,17 @@ export async function POST( body = null; } - const email = readEmail(body); - if (!email) { + const rawEmail = readEmail(body); + if (!rawEmail) { return NextResponse.json( { error: "Valid email is required" }, { status: 400 }, ); } + // Collaborator emails are always stored lowercased for consistent lookups. + const email = rawEmail.toLowerCase(); + // Check if already a collaborator const existing = await prisma.projectCollaborator.findUnique({ where: { @@ -155,7 +162,7 @@ export async function POST( const ownerEmail = owner.emailAddresses.find( (e) => e.id === owner.primaryEmailAddressId ); - if (ownerEmail?.emailAddress.toLowerCase() === email.toLowerCase()) { + if (ownerEmail?.emailAddress.toLowerCase() === email) { return NextResponse.json( { error: "Cannot invite the project owner" }, { status: 400 }, @@ -165,13 +172,46 @@ export async function POST( const collaborator = await prisma.projectCollaborator.create({ data: { projectId: project.id, - email: email.toLowerCase(), + email, }, }); + // If the invitee already has a Clerk account, grant Liveblocks room + // access immediately so they can open the project without waiting for + // a subsequent auth-endpoint updateRoom call. Auth still re-grants + // access on every join (covers users who sign up after the invite). + try { + const { liveblocks } = await import("@/lib/liveblocks"); + const inviteeList = await clerk.users.getUserList({ + emailAddress: [email], + limit: 1, + }); + const inviteeData = (inviteeList as unknown as { data: { id: string }[] }) + .data; + const inviteeId = inviteeData?.[0]?.id; + if (inviteeId) { + await liveblocks.getOrCreateRoom(project.id, { + defaultAccesses: [], + usersAccesses: { + [userId]: ["room:write"], + [inviteeId]: ["room:write"], + }, + }); + await liveblocks.updateRoom(project.id, { + usersAccesses: { + [inviteeId]: ["room:write"], + }, + }); + } + } catch (error) { + // Membership is already in Postgres; Liveblocks access will be + // granted when the collaborator hits /api/liveblocks-auth. + console.error("Failed to grant Liveblocks access on invite", error); + } + // Try to get Clerk user data for the response const { getClerkUserByEmail } = await import("@/lib/auth"); - const clerkUser = await getClerkUserByEmail(email.toLowerCase()); + const clerkUser = await getClerkUserByEmail(email); return NextResponse.json( { diff --git a/components/editor/canvas-controls.tsx b/components/editor/canvas-controls.tsx index c9f5742..880ede0 100644 --- a/components/editor/canvas-controls.tsx +++ b/components/editor/canvas-controls.tsx @@ -48,7 +48,7 @@ export function CanvasControls() { }, [reactFlow]); return ( -
+
void; } /** @@ -77,17 +87,24 @@ interface CanvasProps { * the foundation of the editor — node/edge rendering and persistence * land in later features. */ -export function Canvas({ roomId }: CanvasProps) { +export function Canvas({ + roomId, + templatesOpen = false, + onTemplatesOpenChange, +}: CanvasProps) { return ( }> - + @@ -155,13 +172,23 @@ function generateNodeId(shape: NodeShape): string { return `${shape}-${timestamp}-${nodeIdCounter}`; } +interface CollaborativeFlowProps { + templatesOpen: boolean; + onTemplatesOpenChange?: (open: boolean) => void; +} + /** * The actual React Flow surface, rendered after Liveblocks has connected * and the storage layer is ready (via Suspense). */ -function CollaborativeFlow() { +function CollaborativeFlow({ + templatesOpen, + onTemplatesOpenChange, +}: CollaborativeFlowProps) { const wrapperRef = useRef(null); - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition, fitView } = useReactFlow(); + const { onPointerMove: onCursorMove, onPointerLeave: onCursorLeave } = + useCursorPresence(); // `useLiveblocksFlow` returns the synced nodes/edges plus change // handlers. With `suspense: true`, `nodes` and `edges` are guaranteed @@ -187,6 +214,48 @@ function CollaborativeFlow() { [lbOnNodesChange], ); + /** + * Replace the entire collaborative graph with a starter template. + * Clears existing nodes/edges first, then adds the template graph and + * fits the view once the new elements are in place. + */ + const handleImportTemplate = useCallback( + (template: CanvasTemplate) => { + const { nodes: nextNodes, edges: nextEdges } = + cloneTemplateWithFreshIds(template); + + // Remove every existing edge, then every existing node. + if (edges.length > 0) { + onEdgesChange( + edges.map((edge) => ({ type: "remove" as const, id: edge.id })), + ); + } + if (nodes.length > 0) { + onNodesChange( + nodes.map((node) => ({ type: "remove" as const, id: node.id })), + ); + } + + // Add template graph into collaborative storage. + if (nextNodes.length > 0) { + onNodesChange( + nextNodes.map((node) => ({ type: "add" as const, item: node })), + ); + } + if (nextEdges.length > 0) { + onEdgesChange( + nextEdges.map((edge) => ({ type: "add" as const, item: edge })), + ); + } + + // Fit after React Flow has applied the new graph. + window.setTimeout(() => { + void fitView({ duration: 200, padding: 0.15 }); + }, 50); + }, + [edges, nodes, onEdgesChange, onNodesChange, fitView], + ); + // Memoized type maps — stable refs so React Flow does not remount nodes/edges const nodeTypes = useMemo( () => ({ @@ -272,6 +341,8 @@ function CollaborativeFlow() { onEdgesChange={onEdgesChange} onConnect={onConnect} onDelete={onDelete} + onMouseMove={onCursorMove} + onMouseLeave={onCursorLeave} nodeTypes={nodeTypes} edgeTypes={edgeTypes} defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} @@ -288,9 +359,20 @@ function CollaborativeFlow() { /> + + + {/* Zoom + undo/redo bar (bottom-left); shape palette (bottom-center) */} + + {onTemplatesOpenChange ? ( + + ) : null}
); } diff --git a/components/editor/editor-navbar.tsx b/components/editor/editor-navbar.tsx index c780491..58f12a4 100644 --- a/components/editor/editor-navbar.tsx +++ b/components/editor/editor-navbar.tsx @@ -13,8 +13,8 @@ export function EditorNavbar({ onToggleSidebar, }: EditorNavbarProps) { return ( -
-
+
+
-
-
+
diff --git a/components/editor/editor-shell.tsx b/components/editor/editor-shell.tsx index 4329d97..ac62dff 100644 --- a/components/editor/editor-shell.tsx +++ b/components/editor/editor-shell.tsx @@ -43,24 +43,24 @@ export function EditorShell({ initialOwned, initialShared }: EditorShellProps) { const isDeleteOpen = dialog.kind === "delete"; return ( -
+
+
+ +
setIsSidebarOpen((open) => !open)} /> -
- setIsSidebarOpen(false)} - ownedProjects={ownedProjects} - sharedProjects={sharedProjects} - onCreateProject={openCreate} - onRenameProject={openRename} - onDeleteProject={openDelete} - onOpenProject={(projectId) => router.push(`/editor/${projectId}`)} - /> - -
+ setIsSidebarOpen(false)} + ownedProjects={ownedProjects} + sharedProjects={sharedProjects} + onCreateProject={openCreate} + onRenameProject={openRename} + onDeleteProject={openDelete} + onOpenProject={(projectId) => router.push(`/editor/${projectId}`)} + /> + {others.map((other) => { + const cursor = other.presence.cursor; + if (!cursor) return null; + + const screenX = cursor.x * zoom + vx; + const screenY = cursor.y * zoom + vy; + const color = other.info.color; + const name = other.info.name || "Collaborator"; + + return ( +
+ +
+ {name} +
+
+ ); + })} +
+ ); +} + +/** + * Hook: broadcast cursor in flow coordinates on React Flow pointer events. + */ +export function useCursorPresence() { + const updateMyPresence = useUpdateMyPresence(); + const { screenToFlowPosition } = useReactFlow(); + + const onPointerMove = useCallback( + (event: MouseEvent) => { + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + updateMyPresence({ + cursor: { x: position.x, y: position.y }, + }); + }, + [screenToFlowPosition, updateMyPresence], + ); + + const onPointerLeave = useCallback(() => { + updateMyPresence({ cursor: null }); + }, [updateMyPresence]); + + return { onPointerMove, onPointerLeave }; +} + +function CursorPointer({ color }: { color: string }) { + return ( + + + + ); +} diff --git a/components/editor/presence-avatars.tsx b/components/editor/presence-avatars.tsx new file mode 100644 index 0000000..f63d7fe --- /dev/null +++ b/components/editor/presence-avatars.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { UserButton, useUser } from "@clerk/nextjs"; +import { useOthers } from "@liveblocks/react/suspense"; + +import { cn } from "@/lib/utils"; + +const MAX_VISIBLE = 5; +const AVATAR_SIZE = "h-9 w-9"; + +/** + * Top-right presence group for the editor canvas room only. + * Collaborator avatars (excluding the current Clerk user) + Clerk UserButton. + */ +export function PresenceAvatars() { + const { user } = useUser(); + const others = useOthers(); + + const currentUserId = user?.id ?? null; + + const collaborators = others.filter((other) => { + if (!currentUserId) return true; + return other.id !== currentUserId; + }); + + const visible = collaborators.slice(0, MAX_VISIBLE); + const overflow = collaborators.length - MAX_VISIBLE; + const hasCollaborators = collaborators.length > 0; + + return ( +
+ {hasCollaborators ? ( +
+
+ {visible.map((other) => ( + + ))} + {overflow > 0 ? ( +
+ +{overflow} +
+ ) : null} +
+
+
+ ) : null} + +
+ +
+
+ ); +} + +interface CollaboratorAvatarProps { + name: string; + avatar: string; + color: string; +} + +function CollaboratorAvatar({ name, avatar, color }: CollaboratorAvatarProps) { + const initials = getInitials(name); + + return ( +
+ {avatar ? ( + // eslint-disable-next-line @next/next/no-img-element -- Liveblocks avatar URLs are external + + ) : ( + + {initials} + + )} +
+ ); +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} diff --git a/components/editor/project-sidebar.tsx b/components/editor/project-sidebar.tsx index 4772b97..7e48c5e 100644 --- a/components/editor/project-sidebar.tsx +++ b/components/editor/project-sidebar.tsx @@ -48,8 +48,8 @@ export function ProjectSidebar({