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 (
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * 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({