diff --git a/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts b/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts
new file mode 100644
index 0000000..211ca0d
--- /dev/null
+++ b/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts
@@ -0,0 +1,53 @@
+import { NextResponse } from "next/server";
+
+import { getCurrentUserId } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+/**
+ * Remove a collaborator. Only the owner may remove.
+ */
+export async function DELETE(
+ request: Request,
+ {
+ params,
+ }: {
+ params: Promise<{ projectId: string; collaboratorId: string }>;
+ },
+) {
+ const userId = await getCurrentUserId();
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { projectId, collaboratorId } = await params;
+
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ select: { id: true, ownerId: true },
+ });
+
+ if (!project) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ // Only owner can remove collaborators
+ if (project.ownerId !== userId) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ // Find the collaborator
+ const collaborator = await prisma.projectCollaborator.findUnique({
+ where: { id: collaboratorId },
+ select: { id: true, projectId: true },
+ });
+
+ if (!collaborator || collaborator.projectId !== projectId) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ await prisma.projectCollaborator.delete({
+ where: { id: collaboratorId },
+ });
+
+ 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
new file mode 100644
index 0000000..48404c4
--- /dev/null
+++ b/app/api/projects/[projectId]/collaborators/route.ts
@@ -0,0 +1,187 @@
+import { NextResponse } from "next/server";
+
+import { getCurrentUserId, getClerkUsersByEmails } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+
+interface InviteBody {
+ email?: unknown;
+}
+
+function readEmail(body: unknown): string | null {
+ if (body === null || typeof body !== "object") return null;
+ const raw = (body as InviteBody).email;
+ if (typeof raw !== "string") return null;
+ const trimmed = raw.trim();
+ // Basic email validation
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return emailRegex.test(trimmed) ? trimmed : null;
+}
+
+/**
+ * List collaborators for a project. Owner and collaborators can view.
+ */
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ projectId: string }> },
+) {
+ const userId = await getCurrentUserId();
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { projectId } = await params;
+
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ select: { id: true, ownerId: true },
+ });
+
+ if (!project) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ // Check access: owner or collaborator
+ const isOwner = project.ownerId === userId;
+
+ // Get user's email from Clerk
+ const { clerkClient } = await import("@clerk/nextjs/server");
+ const clerk = await clerkClient();
+ const user = await clerk.users.getUser(userId);
+ const primaryEmail = user.emailAddresses.find(
+ (e) => e.id === user.primaryEmailAddressId
+ );
+ const userEmail = primaryEmail?.emailAddress ?? "";
+
+ const actualCollaborator = await prisma.projectCollaborator.findUnique({
+ where: {
+ projectId_email: {
+ projectId: project.id,
+ email: userEmail,
+ },
+ },
+ });
+
+ const hasAccess = isOwner || !!actualCollaborator;
+ if (!hasAccess) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const collaborators = await prisma.projectCollaborator.findMany({
+ where: { projectId },
+ orderBy: { createdAt: "asc" },
+ });
+
+ // Enrich with Clerk user data
+ const emails = collaborators.map((c) => c.email);
+ const clerkUsers = await getClerkUsersByEmails(emails);
+
+ const enrichedCollaborators = collaborators.map((c) => {
+ const clerkUser = clerkUsers.get(c.email);
+ return {
+ ...c,
+ createdAt: c.createdAt.toISOString(),
+ name: clerkUser?.name ?? null,
+ avatarUrl: clerkUser?.avatarUrl ?? null,
+ };
+ });
+
+ return NextResponse.json({ collaborators: enrichedCollaborators });
+}
+
+/**
+ * Invite a collaborator. Only the owner may invite.
+ */
+export async function POST(
+ request: Request,
+ { params }: { params: Promise<{ projectId: string }> },
+) {
+ const userId = await getCurrentUserId();
+ if (!userId) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const { projectId } = await params;
+
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ select: { id: true, ownerId: true },
+ });
+
+ if (!project) {
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
+ }
+
+ // Only owner can invite
+ if (project.ownerId !== userId) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ let body: unknown = null;
+ try {
+ body = await request.json();
+ } catch {
+ body = null;
+ }
+
+ const email = readEmail(body);
+ if (!email) {
+ return NextResponse.json(
+ { error: "Valid email is required" },
+ { status: 400 },
+ );
+ }
+
+ // Check if already a collaborator
+ const existing = await prisma.projectCollaborator.findUnique({
+ where: {
+ projectId_email: {
+ projectId: project.id,
+ email,
+ },
+ },
+ });
+
+ if (existing) {
+ return NextResponse.json(
+ { error: "User is already a collaborator" },
+ { status: 409 },
+ );
+ }
+
+ // Don't allow inviting the owner
+ const { clerkClient } = await import("@clerk/nextjs/server");
+ const clerk = await clerkClient();
+ const owner = await clerk.users.getUser(project.ownerId);
+ const ownerEmail = owner.emailAddresses.find(
+ (e) => e.id === owner.primaryEmailAddressId
+ );
+ if (ownerEmail?.emailAddress.toLowerCase() === email.toLowerCase()) {
+ return NextResponse.json(
+ { error: "Cannot invite the project owner" },
+ { status: 400 },
+ );
+ }
+
+ const collaborator = await prisma.projectCollaborator.create({
+ data: {
+ projectId: project.id,
+ email: email.toLowerCase(),
+ },
+ });
+
+ // Try to get Clerk user data for the response
+ const { getClerkUserByEmail } = await import("@/lib/auth");
+ const clerkUser = await getClerkUserByEmail(email.toLowerCase());
+
+ return NextResponse.json(
+ {
+ collaborator: {
+ ...collaborator,
+ createdAt: collaborator.createdAt.toISOString(),
+ name: clerkUser?.name ?? null,
+ avatarUrl: clerkUser?.avatarUrl ?? null,
+ },
+ },
+ { status: 201 },
+ );
+}
\ No newline at end of file
diff --git a/app/editor/[roomId]/page.tsx b/app/editor/[roomId]/page.tsx
new file mode 100644
index 0000000..a33d94e
--- /dev/null
+++ b/app/editor/[roomId]/page.tsx
@@ -0,0 +1,66 @@
+import { redirect } from "next/navigation";
+
+import { getCurrentUser } from "@/lib/auth";
+import { getProjectWithAccess } from "@/lib/project-access";
+import { getAllProjects } from "@/lib/projects";
+import { slugify } from "@/lib/mock-projects";
+import { WorkspaceShell } from "@/components/editor/workspace-shell";
+
+interface EditorRoomPageProps {
+ params: Promise<{ roomId: string }>;
+}
+
+/**
+ * Editor workspace page for a specific project. Server component that checks
+ * access permissions and renders the workspace shell.
+ */
+export default async function EditorRoomPage({ params }: EditorRoomPageProps) {
+ const { roomId } = await params;
+
+ // Check authentication
+ const currentUser = await getCurrentUser();
+ if (!currentUser) {
+ redirect("/sign-in");
+ }
+
+ // Check project access
+ const project = await getProjectWithAccess(roomId);
+ if (!project) {
+ // Return the shell with AccessDenied - we'll render it client-side
+ return (
+
+ );
+ }
+
+ // Fetch all projects for the sidebar (owned by userId, shared by email)
+ const { owned, shared } = await getAllProjects(currentUser.userId, currentUser.email);
+
+ // Transform to UI shape
+ const ownedProjects = owned.map((p) => ({
+ id: p.id,
+ name: p.name,
+ slug: slugify(p.name),
+ role: "owner" as const,
+ }));
+
+ const sharedProjects = shared.map((p) => ({
+ id: p.id,
+ name: p.name,
+ slug: slugify(p.name),
+ role: "collaborator" as const,
+ }));
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/app/editor/page.tsx b/app/editor/page.tsx
index 28a71f2..84d5af2 100644
--- a/app/editor/page.tsx
+++ b/app/editor/page.tsx
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
-import { getCurrentUserId } from "@/lib/auth";
+import { getCurrentUser } from "@/lib/auth";
import { getAllProjects } from "@/lib/projects";
import { slugify } from "@/lib/mock-projects";
import { EditorShell } from "@/components/editor/editor-shell";
@@ -10,14 +10,14 @@ import { EditorShell } from "@/components/editor/editor-shell";
* server-side and passes them to the client shell for dialog/mutation handling.
*/
export default async function EditorPage() {
- const userId = await getCurrentUserId();
+ const currentUser = await getCurrentUser();
// Redirect unauthenticated users to sign-in
- if (!userId) {
+ if (!currentUser) {
redirect("/sign-in");
}
- const { owned, shared } = await getAllProjects(userId);
+ const { owned, shared } = await getAllProjects(currentUser.userId, currentUser.email);
// Transform database records to the UI shape
const ownedProjects = owned.map((p) => ({
diff --git a/components/editor/access-denied.tsx b/components/editor/access-denied.tsx
new file mode 100644
index 0000000..726d0fb
--- /dev/null
+++ b/components/editor/access-denied.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { Lock } from "lucide-react";
+import Link from "next/link";
+
+/**
+ * AccessDenied component shown when a user doesn't have access to a project.
+ * Displays a centered layout with a lock icon, message, and link back to editor.
+ */
+export function AccessDenied() {
+ return (
+
+
+
+
+
+
+ Access Denied
+
+
+ You don't have access to this project. It may not exist or you
+ may not be invited as a collaborator.
+
+
+ ← Back to Editor
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/editor/editor-shell.tsx b/components/editor/editor-shell.tsx
index 262f9bc..4329d97 100644
--- a/components/editor/editor-shell.tsx
+++ b/components/editor/editor-shell.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useRouter } from "next/navigation";
import { EditorHome } from "@/components/editor/editor-home";
import { EditorNavbar } from "@/components/editor/editor-navbar";
@@ -16,6 +17,7 @@ interface EditorShellProps {
}
export function EditorShell({ initialOwned, initialShared }: EditorShellProps) {
+ const router = useRouter();
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const {
ownedProjects,
@@ -55,6 +57,7 @@ export function EditorShell({ initialOwned, initialShared }: EditorShellProps) {
onCreateProject={openCreate}
onRenameProject={openRename}
onDeleteProject={openDelete}
+ onOpenProject={(projectId) => router.push(`/editor/${projectId}`)}
/>
diff --git a/components/editor/project-sidebar.tsx b/components/editor/project-sidebar.tsx
index e41ea6e..4772b97 100644
--- a/components/editor/project-sidebar.tsx
+++ b/components/editor/project-sidebar.tsx
@@ -17,6 +17,7 @@ interface ProjectSidebarProps {
onCreateProject: () => void;
onRenameProject: (projectId: string) => void;
onDeleteProject: (projectId: string) => void;
+ onOpenProject?: (projectId: string) => void;
}
export function ProjectSidebar({
@@ -27,6 +28,7 @@ export function ProjectSidebar({
onCreateProject,
onRenameProject,
onDeleteProject,
+ onOpenProject,
}: ProjectSidebarProps) {
return (
<>
@@ -92,6 +94,7 @@ export function ProjectSidebar({
projects={ownedProjects}
onRename={onRenameProject}
onDelete={onDeleteProject}
+ onOpenProject={onOpenProject}
/>
)}
@@ -106,6 +109,7 @@ export function ProjectSidebar({
projects={sharedProjects}
onRename={onRenameProject}
onDelete={onDeleteProject}
+ onOpenProject={onOpenProject}
/>
)}
@@ -135,9 +139,15 @@ interface ProjectListProps {
projects: ProjectData[];
onRename: (projectId: string) => void;
onDelete: (projectId: string) => void;
+ onOpenProject?: (projectId: string) => void;
}
-function ProjectList({ projects, onRename, onDelete }: ProjectListProps) {
+function ProjectList({
+ projects,
+ onRename,
+ onDelete,
+ onOpenProject,
+}: ProjectListProps) {
return (
{projects.map((project) => (
@@ -146,6 +156,7 @@ function ProjectList({ projects, onRename, onDelete }: ProjectListProps) {
project={project}
onRename={onRename}
onDelete={onDelete}
+ onOpenProject={onOpenProject}
/>
))}
@@ -157,13 +168,25 @@ interface ProjectRowProps {
project: ProjectData;
onRename: (projectId: string) => void;
onDelete: (projectId: string) => void;
+ onOpenProject?: (projectId: string) => void;
}
-function ProjectRow({ project, onRename, onDelete }: ProjectRowProps) {
+function ProjectRow({
+ project,
+ onRename,
+ onDelete,
+ onOpenProject,
+}: ProjectRowProps) {
const isOwned = project.role === "owner";
const [menuOpen, setMenuOpen] = React.useState(false);
const menuRef = React.useRef(null);
+ function handleClick() {
+ if (onOpenProject) {
+ onOpenProject(project.id);
+ }
+ }
+
React.useEffect(() => {
if (!menuOpen) return;
function handlePointerDown(event: MouseEvent) {
@@ -185,8 +208,16 @@ function ProjectRow({ project, onRename, onDelete }: ProjectRowProps) {
return (
{
+ if (e.key === "Enter" || e.key === " ") {
+ handleClick();
+ }
+ }}
className={cn(
- "group flex items-center gap-2 rounded-xl border border-transparent px-3 py-2 transition-colors",
+ "group flex cursor-pointer items-center gap-2 rounded-xl border border-transparent px-3 py-2 transition-colors",
"hover:border-border-subtle hover:bg-bg-elevated",
)}
>
diff --git a/components/editor/share-dialog.tsx b/components/editor/share-dialog.tsx
new file mode 100644
index 0000000..9255907
--- /dev/null
+++ b/components/editor/share-dialog.tsx
@@ -0,0 +1,217 @@
+"use client";
+
+import * as React from "react";
+import { Copy, Link, Loader2, Trash2, UserPlus } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { DialogPattern } from "@/components/editor/dialog-pattern";
+import { Input } from "@/components/ui/input";
+
+export interface CollaboratorData {
+ id: string;
+ email: string;
+ createdAt: string;
+ name?: string | null;
+ avatarUrl?: string | null;
+}
+
+interface ShareDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ projectId: string;
+ isOwner: boolean;
+ collaborators: CollaboratorData[];
+ isLoading: boolean;
+ error: string | null;
+ inviteEmail: string;
+ onInviteEmailChange: (value: string) => void;
+ onInvite: () => void;
+ onRemove: (collaboratorId: string) => void;
+ onCopyLink: () => void;
+ copied: boolean;
+}
+
+export function ShareDialog({
+ open,
+ onOpenChange,
+ projectId,
+ isOwner,
+ collaborators,
+ isLoading,
+ error,
+ inviteEmail,
+ onInviteEmailChange,
+ onInvite,
+ onRemove,
+ onCopyLink,
+ copied,
+}: ShareDialogProps) {
+ const inputRef = React.useRef
(null);
+
+ React.useEffect(() => {
+ if (open) {
+ // Wait for the dialog open animation to settle before focusing.
+ const id = window.setTimeout(() => inputRef.current?.focus(), 0);
+ return () => window.clearTimeout(id);
+ }
+ }, [open]);
+
+ const projectLink = `/editor/${projectId}`;
+
+ return (
+
+
+ {/* Copy link section */}
+
+
+ Project link
+
+
+
+
+
+
+
+ {copied ? (
+ "Copied!"
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+
+
+
+ {/* Invite section - only for owners */}
+ {isOwner && (
+
+
+ Invite collaborator
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )}
+
+ {/* Collaborators list */}
+
+
+ Collaborators
+
+ {collaborators.length === 0 ? (
+
+ No collaborators yet. Invite someone to collaborate.
+
+ ) : (
+
+ {collaborators.map((collaborator) => (
+
+
+ {collaborator.avatarUrl ? (
+
+ ) : (
+
+ {collaborator.email[0].toUpperCase()}
+
+ )}
+
+
+ {collaborator.name ?? collaborator.email}
+
+ {!collaborator.name && (
+
+ {collaborator.email}
+
+ )}
+
+
+ {isOwner && (
+ onRemove(collaborator.id)}
+ disabled={isLoading}
+ className="text-text-muted hover:text-state-error"
+ aria-label={`Remove ${collaborator.email}`}
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {/* Read-only notice for collaborators */}
+ {!isOwner && collaborators.length > 0 && (
+
+ You have view-only access. Contact the project owner to manage
+ collaborators.
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/editor/workspace-shell.tsx b/components/editor/workspace-shell.tsx
new file mode 100644
index 0000000..20215d0
--- /dev/null
+++ b/components/editor/workspace-shell.tsx
@@ -0,0 +1,434 @@
+"use client";
+
+import { useState } from "react";
+import { UserButton } from "@clerk/nextjs";
+import { PanelLeftClose, PanelLeftOpen, Share2, MessageSquare, X } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
+import type { ProjectData } from "@/hooks/use-project-actions";
+import { AccessDenied } from "@/components/editor/access-denied";
+import { ShareDialog } from "@/components/editor/share-dialog";
+import { useShareDialog } from "@/hooks/use-share-dialog";
+
+interface WorkspaceShellProps {
+ project: { id: string; name: string; ownerId: string } | null;
+ currentRoomId: string;
+ ownedProjects: ProjectData[];
+ sharedProjects: ProjectData[];
+}
+
+/**
+ * Workspace shell for the editor. Contains the full-viewport layout with
+ * navbar, left sidebar, canvas area, and right AI chat sidebar placeholder.
+ */
+export function WorkspaceShell({
+ project,
+ currentRoomId,
+ ownedProjects,
+ sharedProjects,
+}: WorkspaceShellProps) {
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true);
+ const [isAiSidebarOpen, setIsAiSidebarOpen] = useState(false);
+
+ // Determine if current user is owner by checking against the projects list
+ const isOwner = project
+ ? ownedProjects.some((p) => p.id === project.id && p.role === "owner")
+ : false;
+
+ // Share dialog state
+ const shareDialog = useShareDialog(
+ project?.id ?? "",
+ isOwner,
+ );
+
+ // If no project access, show access denied
+ if (!project) {
+ return (
+
+
setIsSidebarOpen((open) => !open)}
+ onToggleAiSidebar={() => setIsAiSidebarOpen((open) => !open)}
+ aiSidebarOpen={isAiSidebarOpen}
+ onShareClick={() => {}}
+ />
+
+ setIsSidebarOpen(false)}
+ ownedProjects={ownedProjects}
+ sharedProjects={sharedProjects}
+ currentRoomId={currentRoomId}
+ />
+
+ setIsAiSidebarOpen(false)}
+ />
+
+
+ );
+ }
+
+ return (
+
+
setIsSidebarOpen((open) => !open)}
+ onToggleAiSidebar={() => setIsAiSidebarOpen((open) => !open)}
+ aiSidebarOpen={isAiSidebarOpen}
+ onShareClick={shareDialog.onOpenChange}
+ />
+
+ setIsSidebarOpen(false)}
+ ownedProjects={ownedProjects}
+ sharedProjects={sharedProjects}
+ currentRoomId={currentRoomId}
+ />
+
+ setIsAiSidebarOpen(false)}
+ />
+
+
+ {/* Share Dialog */}
+
+
+ );
+}
+
+interface EditorNavbarProps {
+ projectName: string;
+ isSidebarOpen: boolean;
+ aiSidebarOpen: boolean;
+ onToggleSidebar: () => void;
+ onToggleAiSidebar: () => void;
+ onShareClick: (open: boolean) => void;
+}
+
+function EditorNavbar({
+ projectName,
+ isSidebarOpen,
+ aiSidebarOpen,
+ onToggleSidebar,
+ onToggleAiSidebar,
+ onShareClick,
+}: EditorNavbarProps) {
+ return (
+
+ );
+}
+
+interface ProjectSidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+ ownedProjects: ProjectData[];
+ sharedProjects: ProjectData[];
+ currentRoomId: string;
+}
+
+function ProjectSidebar({
+ isOpen,
+ onClose,
+ ownedProjects,
+ sharedProjects,
+ currentRoomId,
+}: ProjectSidebarProps) {
+ return (
+ <>
+ {/* Mobile backdrop */}
+
+
+
+
Projects
+
+
+
+
+
+
+
+
+
+ My Projects
+
+
+ Shared
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {}}
+ >
+
+ New Project
+
+
+
+ >
+ );
+}
+
+interface ProjectListProps {
+ projects: ProjectData[];
+ currentRoomId: string;
+}
+
+function ProjectList({
+ projects,
+ currentRoomId,
+}: ProjectListProps) {
+ if (projects.length === 0) {
+ return (
+
+ No projects
+
+ );
+ }
+
+ return (
+
+ {projects.map((project) => (
+
+
+
+ ))}
+
+ );
+}
+
+interface ProjectRowProps {
+ project: ProjectData;
+ isActive: boolean;
+}
+
+function ProjectRow({ project, isActive }: ProjectRowProps) {
+ return (
+
+
+
+ {project.name}
+
+
+ /{project.slug}
+
+
+
+ );
+}
+
+/**
+ * Canvas placeholder - dark background with centered message.
+ * Real canvas logic will be added later.
+ */
+function CanvasPlaceholder() {
+ return (
+
+
+
+ Canvas placeholder — canvas logic coming soon
+
+
+
+ );
+}
+
+interface AiSidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+/**
+ * Right sidebar placeholder for future AI chat.
+ */
+function AiSidebar({ isOpen, onClose }: AiSidebarProps) {
+ return (
+ <>
+ {/* Mobile backdrop for AI sidebar */}
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/context/feature-specs/08-editor-workspace-shell.md b/context/feature-specs/08-editor-workspace-shell.md
new file mode 100644
index 0000000..2d5b648
--- /dev/null
+++ b/context/feature-specs/08-editor-workspace-shell.md
@@ -0,0 +1,50 @@
+Build the `/editor/[roomId]` workspace shell with server-side access checks. No canvas logic yet.
+
+## Access
+
+`/editor/[roomId]` must be a server component.
+
+Before rendering:
+
+- unauthenticated users redirect to `/sign-in`
+- users without project access see `AccessDenied`
+- non-existent projects also show `AccessDenied`
+
+Create `components/editor/access-denied.tsx` with:
+
+- centered layout
+- lock icon
+- short message
+- link back to `/editor`
+
+## Access Helpers
+
+Create `lib/project-access.ts` with helpers for:
+
+- getting current Clerk identity: `userId` + primary email
+- checking project access by owner or collaborator
+
+## Layout
+
+Build a full-viewport workspace layout with:
+
+- top navbar showing the project name
+- navbar actions: share button and AI sidebar toggle
+- existing `ProjectSidebar` on the left
+- current room highlighted in the sidebar
+- central canvas placeholder with dark background and centered message
+- right sidebar placeholder for future AI chat
+
+The canvas area should fill the remaining space.
+
+## Scope
+
+Do not add real canvas logic, Liveblocks, AI chat, or sharing behavior yet.
+
+## Check When Done
+
+- `/editor/[roomId]` builds successfully
+- access helper exists outside the page component
+- `AccessDenied` is used for missing or unauthorized projects
+- workspace layout renders with current project context
+- no TypeScript errors
diff --git a/context/feature-specs/09-share-dialog.md b/context/feature-specs/09-share-dialog.md
new file mode 100644
index 0000000..a2e3151
--- /dev/null
+++ b/context/feature-specs/09-share-dialog.md
@@ -0,0 +1,48 @@
+Add sharing to the workspace so project owners can invite collaborators by email.
+
+## Share Dialog
+
+Add a `Share` button to the editor navbar that opens the share dialog.
+
+Owners can:
+
+- invite collaborators by email
+- view current collaborators
+- remove collaborators
+- copy the project link with temporary `Copied!` feedback
+
+Collaborators can:
+
+- view the collaborator list only
+- not invite, remove, or manage access
+
+## Clerk User Data
+
+Collaborators are stored by email in the database.
+
+Use Clerk Backend API to enrich collaborator emails with:
+
+- display name
+- avatar image
+
+If a Clerk user is not found for an email, fall back to showing the email only.
+
+## Implementation
+
+Add the required API logic for:
+
+- listing collaborators
+- inviting collaborators
+- removing collaborators
+
+Enforce ownership server-side for invite and remove actions.
+
+Do not add a local user table.
+
+## Check When Done
+
+- share dialog opens from the workspace
+- owners can invite and remove collaborators
+- collaborators see read-only access
+- collaborator names/avatars load from Clerk when available
+- `npm run build` passes
diff --git a/context/progress-tracker.md b/context/progress-tracker.md
index 9310838..689e940 100644
--- a/context/progress-tracker.md
+++ b/context/progress-tracker.md
@@ -4,7 +4,7 @@ Update this file whenever the current phase, active feature, or implementation s
## Current Phase
-- Feature 07 (Wire editor home to project API) — completed
+- Feature 09 (Share dialog) — completed
## Current Goal
@@ -19,6 +19,8 @@ Update this file whenever the current phase, active feature, or implementation s
- feature 05: Prisma schema & data layer — schema split: `prisma/schema.prisma` keeps the datasource + generator blocks, `prisma/models/project.prisma` defines the models. `Project` carries `ownerId`, `name`, optional `description`, `ProjectStatus` enum (`DRAFT` / `ARCHIVED`) defaulting to `DRAFT`, optional `canvasJsonPath` blob reference, and `createdAt` / `updatedAt` timestamps; indexes on `ownerId` and `createdAt`. `ProjectCollaborator` carries `projectId` (FK to `Project` with `onDelete: Cascade`), `email`, and `createdAt`; unique constraint on `(projectId, email)`; indexes on `email` and `(projectId, createdAt)`. `lib/prisma.ts` is a cached singleton — in dev, stashed on `globalThis` to survive HMR — that branches on `DATABASE_URL`: `prisma+postgres://` uses Prisma Accelerate (`accelerateUrl` + `withAccelerate()` extension), anything else uses `PrismaPg` driver adapter. Generated client lives in `app/generated/prisma` and is gitignored. Initial migration `20260715063438_init_project_models` applied. `npm run build` passes; no new lint errors (the two pre-existing shadcn `interface` warnings in `components/ui/input.tsx` and `components/ui/textarea.tsx` are unchanged).
- feature 06: Project API routes — `lib/auth.ts` exposes `getCurrentUserId()` which returns the Clerk `userId` from `auth()` (or `null` if unauthenticated). `app/api/projects/route.ts` handles `GET` (lists the current user's projects, ordered by `createdAt` desc, uses the existing `ownerId` index) and `POST` (creates a project owned by the current user, defaults missing/blank name to `Untitled Project`, id stays on Prisma's existing `@default(cuid())` — no sequential IDs added). `app/api/projects/[projectId]/route.ts` handles `PATCH` (renames, requires a non-empty trimmed name) and `DELETE`. Both dynamic-route handlers resolve `params` as a `Promise` per Next.js 16, look up the project with `findUnique({ select: { id, ownerId }})`, return 404 when missing and 403 when the current user is not the owner, and return 401 for unauthenticated requests. `DELETE` returns 204 with an empty body; `PATCH` returns the updated project. Body parsing is wrapped in `try`/`catch` so a missing or malformed body resolves to a 400 on `PATCH` and to the default name on `POST`. `npm run build` passes; no new lint errors (the two pre-existing shadcn warnings are unchanged).
- feature 07: Wire editor home to project API — Editor page converted to server component that fetches owned and shared projects server-side via `getAllProjects()` from `lib/projects.ts`. Client-side state and mutations managed by new `useProjectActions` hook in `hooks/use-project-actions.ts` which handles create (generates room ID with slug + suffix, POSTs to API, navigates to new workspace), rename (PATCH to API, updates local state on success), and delete (DELETE to API, refreshes router on success). `CreateProjectDialog` now shows "Room ID" preview instead of "Slug". Sidebar updated to accept `ProjectData` type from the hook instead of `MockProject`. `app/editor/page.tsx` now redirects unauthenticated users to `/sign-in`. Old `useProjectDialogs` hook removed. `npm run build` passes; no new lint errors.
+- feature 08: Editor workspace shell — Built `/editor/[roomId]` workspace shell with server-side access checks. Created `lib/project-access.ts` with `getCurrentUser()` (returns `userId` + primary email from Clerk) and `getProjectWithAccess()` (checks owner or collaborator access). Created `components/editor/access-denied.tsx` with centered layout, lock icon, message, and link back to `/editor`. Created `app/editor/[roomId]/page.tsx` as a server component that checks authentication (redirects to `/sign-in`), validates project access (shows `AccessDenied` for missing/unauthorized projects), and passes data to the client shell. Created `components/editor/workspace-shell.tsx` with full-viewport layout: top navbar showing project name, share button, AI chat toggle, left sidebar (with current room highlighted), central canvas placeholder (dark background + centered message), and right sidebar placeholder for future AI chat. `npm run build` passes; no new lint errors (pre-existing shadcn warnings in `components/ui/input.tsx` and `components/ui/textarea.tsx` unchanged).
+- feature 09: Share dialog — Added Share button to the editor navbar that opens the share dialog. Created `app/api/projects/[projectId]/collaborators/route.ts` for listing and inviting collaborators, and `app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts` for removing collaborators. Both enforce ownership server-side for invite and remove actions. Created `lib/auth.ts` helpers `getClerkUserByEmail()` and `getClerkUsersByEmails()` to enrich collaborator emails with display name and avatar from Clerk (falls back to showing email only when user not found). Created `components/editor/share-dialog.tsx` with the share dialog UI that shows project link (copyable with "Copied!" feedback), invite form (owners only), collaborator list (shows avatar + name when available from Clerk, email otherwise), and remove button (owners only). Created `hooks/use-share-dialog.ts` to manage share dialog state and API interactions. Updated `components/editor/workspace-shell.tsx` to include the share dialog and wire the Share button. Updated `app/editor/[roomId]/page.tsx` to pass `ownerId` to determine owner vs collaborator view. `npm run build` passes; no new lint errors.
## In Progress
@@ -26,7 +28,7 @@ Update this file whenever the current phase, active feature, or implementation s
## Next Up
-- feature 08: TBD
+- feature 10: TBD
## Open Questions
@@ -47,3 +49,4 @@ Update this file whenever the current phase, active feature, or implementation s
- 2026-07-15: feature 04 implemented exactly per spec. Dialog state is centralized in `useProjectDialogs` (no `useReducer`; small surface, `useState` + `useTransition` is enough). The dialog form pattern (controlled name value, ref-based auto-focus on open) means the hook owns the field state, so the dialog components stay presentational. Slug preview uses the same `slugify` helper that mutation uses, so the preview matches the stored value. Mobile backdrop is a sibling `` rendered before the `` and only enabled on `md:hidden` — clicking it calls `onClose`, which is the same prop the X button uses. Two pre-existing lint errors in `components/ui/input.tsx` and `components/ui/textarea.tsx` (empty `extends React.HTMLAttributes` interfaces from shadcn) are unchanged — those files are protected foundation components.
- 2026-07-15: feature 05 implemented exactly per spec. Schema split mirrors the planned "one file per aggregate" pattern from the context files (the spec's `prisma/models/project.prisma` becomes the home for project-related models, leaving `schema.prisma` as a thin shell with just `generator` + `datasource`). `ProjectCollaborator` was modelled with the `project` relation field plus the FK column — Prisma only requires one side, but having the typed `project` field on the child model means callers can `include: { project: true }` and get a typed return without a second query. The unique constraint on `(projectId, email)` is the project's hard rule (one row per email per project), so it's enforced at the DB layer. The Accelerate branch installs `@prisma/extension-accelerate` (the spec listed it as "Already installed" but the package wasn't in `package.json` — added it) and wires `withAccelerate()` to the base `PrismaClient` constructed with `accelerateUrl`. The non-Accelerate branch uses `new PrismaPg({ connectionString })` per the v7 driver-adapter workflow (`datasourceUrl` does not exist on `PrismaClient` in v7). Both branches return the same `PrismaClient` type so the consumer import is unchanged.
- 2026-07-15: feature 06 implemented exactly per spec. Auth check uses `auth().userId` from `@clerk/nextjs/server` rather than `auth.protect()` — per the Next.js 16 docs, `protect()` returns 404 for unauthenticated API requests, but the spec mandates a 401, so the routes check the auth state explicitly and return `NextResponse.json({ error: "Unauthorized" }, { status: 401 })` themselves. The owner check uses a targeted `findUnique({ select: { id, ownerId } })` (no `select *`) and runs the comparison in TS rather than a Prisma `where` clause — the result codes are cleaner and the lookup doubles as the "does the project exist" check (404 vs 403 vs success). `DELETE` returns 204 with no body (`new NextResponse(null, { status: 204 })`) per REST convention. `PATCH` validates the trimmed `name` and returns 400 on a missing/blank value, so a client that submits an empty rename gets a clear error instead of a silent no-op. Both `params` are awaited as `Promise<{ projectId: string }>` per the Next.js 16 route handler contract (the older `params` is a plain object shape is no longer valid). The collection `POST` accepts an optional body — `request.json()` is wrapped in `try`/`catch` so a request with no body or a malformed body still creates a project with the default name. UI is unchanged (the spec said "Do not wire the UI yet").
+- 2026-07-15: feature 08 implemented exactly per spec. Created `lib/project-access.ts` with `getCurrentUser()` (uses Clerk `clerkClient` to fetch user and get primary email) and `getProjectWithAccess()` (checks owner via `ownerId` match or collaborator via `ProjectCollaborator` unique constraint). Page component at `app/editor/[roomId]/page.tsx` is a server component that redirects unauthenticated users to `/sign-in`, shows `AccessDenied` for missing/unauthorized projects, and passes project data to the client shell. `WorkspaceShell` component renders full-viewport layout with top navbar (project name, share button, AI chat toggle), left sidebar (current room highlighted with accent border), canvas placeholder (dark background + centered text), and right sidebar placeholder for future AI chat. No canvas logic, Liveblocks, AI chat, or sharing behavior added yet per scope. `npm run build` passes; pre-existing shadcn lint errors unchanged.
diff --git a/hooks/use-share-dialog.ts b/hooks/use-share-dialog.ts
new file mode 100644
index 0000000..84e2eba
--- /dev/null
+++ b/hooks/use-share-dialog.ts
@@ -0,0 +1,147 @@
+"use client";
+
+import { useCallback, useState, useTransition } from "react";
+
+import type { CollaboratorData } from "@/components/editor/share-dialog";
+
+/**
+ * State and handlers for the share dialog.
+ */
+export function useShareDialog(projectId: string, isOwner: boolean) {
+ const [open, setOpen] = useState(false);
+ const [inviteEmail, setInviteEmail] = useState("");
+ const [collaborators, setCollaborators] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+ const [isPending, startTransition] = useTransition();
+
+ const openDialog = useCallback(async () => {
+ setOpen(true);
+ setInviteEmail("");
+ setError(null);
+ setCopied(false);
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(`/api/projects/${projectId}/collaborators`);
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error ?? "Failed to load collaborators");
+ }
+
+ const { collaborators: collabs } = await response.json();
+ setCollaborators(collabs);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load collaborators");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [projectId]);
+
+ const closeDialog = useCallback(() => {
+ setOpen(false);
+ setInviteEmail("");
+ setError(null);
+ }, []);
+
+ const submitInvite = useCallback(async () => {
+ const trimmed = inviteEmail.trim();
+ if (!trimmed) {
+ setError("Please enter an email address");
+ return;
+ }
+
+ setError(null);
+ setIsLoading(true);
+
+ try {
+ const response = await fetch(`/api/projects/${projectId}/collaborators`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: trimmed }),
+ });
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error ?? "Failed to invite collaborator");
+ }
+
+ const { collaborator } = await response.json();
+
+ startTransition(() => {
+ setCollaborators((current) => [
+ ...current,
+ { ...collaborator, createdAt: collaborator.createdAt.toISOString() },
+ ]);
+ setInviteEmail("");
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to invite collaborator");
+ } finally {
+ setIsLoading(false);
+ }
+ }, [projectId, inviteEmail]);
+
+ const removeCollaborator = useCallback(
+ async (collaboratorId: string) => {
+ setError(null);
+
+ try {
+ const response = await fetch(
+ `/api/projects/${projectId}/collaborators/${collaboratorId}`,
+ {
+ method: "DELETE",
+ },
+ );
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error ?? "Failed to remove collaborator");
+ }
+
+ startTransition(() => {
+ setCollaborators((current) =>
+ current.filter((c) => c.id !== collaboratorId),
+ );
+ });
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to remove collaborator",
+ );
+ }
+ },
+ [projectId],
+ );
+
+ const copyLink = useCallback(() => {
+ const link = `${window.location.origin}/editor/${projectId}`;
+ navigator.clipboard.writeText(link).then(() => {
+ setCopied(true);
+ // Reset after 2 seconds
+ setTimeout(() => setCopied(false), 2000);
+ });
+ }, [projectId]);
+
+ return {
+ open,
+ isOwner,
+ collaborators,
+ isLoading: isLoading || isPending,
+ error,
+ inviteEmail,
+ onInviteEmailChange: setInviteEmail,
+ onOpenChange: (o: boolean) => {
+ if (o) {
+ openDialog();
+ } else {
+ closeDialog();
+ }
+ },
+ onInvite: submitInvite,
+ onRemove: removeCollaborator,
+ onCopyLink: copyLink,
+ copied,
+ };
+}
\ No newline at end of file
diff --git a/lib/auth.ts b/lib/auth.ts
index 8014972..70542ca 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -1,4 +1,4 @@
-import { auth } from "@clerk/nextjs/server";
+import { auth, clerkClient } from "@clerk/nextjs/server";
/**
* Resolve the current Clerk user ID from the active session, or `null` if no
@@ -9,3 +9,195 @@ export async function getCurrentUserId(): Promise {
const { userId } = await auth();
return userId ?? null;
}
+
+/**
+ * Current user identity from Clerk session.
+ */
+export interface CurrentUser {
+ userId: string;
+ email: string;
+}
+
+/**
+ * Resolve the current user identity (userId + primary email) from the active
+ * Clerk session. Returns null if no user is signed in.
+ */
+export async function getCurrentUser(): Promise {
+ const { userId } = await auth();
+ if (!userId) {
+ return null;
+ }
+
+ // Get the user's primary email from Clerk
+ const clerk = await clerkClient();
+ const user = await clerk.users.getUser(userId);
+
+ // Find the primary email address
+ const primaryEmail = user.emailAddresses.find(
+ (email) => email.id === user.primaryEmailAddressId
+ );
+
+ const email = primaryEmail?.emailAddress ?? user.emailAddresses[0]?.emailAddress ?? "";
+
+ return {
+ userId,
+ email,
+ };
+}
+
+/**
+ * Check if the current user has access to a project (as owner or collaborator).
+ * Returns the project if access is granted, null otherwise.
+ */
+export async function getProjectWithAccess(projectId: string): Promise<{
+ id: string;
+ name: string;
+ ownerId: string;
+} | null> {
+ const currentUser = await getCurrentUser();
+ if (!currentUser) {
+ return null;
+ }
+
+ const { prisma } = await import("@/lib/prisma");
+
+ // First, check if the project exists and user is the owner
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ select: { id: true, name: true, ownerId: true },
+ });
+
+ if (!project) {
+ return null;
+ }
+
+ // Owner has access
+ if (project.ownerId === currentUser.userId) {
+ return project;
+ }
+
+ // Check if user is a collaborator (by email)
+ const collaborator = await prisma.projectCollaborator.findUnique({
+ where: {
+ projectId_email: {
+ projectId: project.id,
+ email: currentUser.email,
+ },
+ },
+ });
+
+ if (collaborator) {
+ return project;
+ }
+
+ // No access
+ return null;
+}
+
+/**
+ * Clerk user data for a collaborator.
+ */
+export interface ClerkUserData {
+ email: string;
+ name: string | null;
+ avatarUrl: string | null;
+}
+
+/**
+ * Look up a Clerk user by email address and return their name and avatar.
+ * Returns null if no user is found for the email.
+ */
+export async function getClerkUserByEmail(
+ email: string,
+): Promise {
+ const clerk = await clerkClient();
+
+ // Search for user by email using Clerk's getUserList
+ const response = await clerk.users.getUserList({
+ emailAddress: [email.toLowerCase()],
+ limit: 1,
+ });
+
+ // Access the data array directly from the paginated response
+ const data = (response as unknown as { data: unknown[] }).data;
+ if (!data || data.length === 0) {
+ return null;
+ }
+
+ const user = data[0] as {
+ fullName: string | null;
+ firstName: string | null;
+ imageUrl: string | null;
+ };
+
+ return {
+ email: email.toLowerCase(),
+ name: user.fullName ?? user.firstName ?? null,
+ avatarUrl: user.imageUrl ?? null,
+ };
+}
+
+/**
+ * Look up multiple Clerk users by email addresses in batch.
+ * Returns a map of email -> ClerkUserData (null for emails not found).
+ */
+export async function getClerkUsersByEmails(
+ emails: string[],
+): Promise> {
+ const clerk = await clerkClient();
+ const result = new Map();
+
+ if (emails.length === 0) {
+ return result;
+ }
+
+ // Clerk allows up to 100 users per request
+ const response = await clerk.users.getUserList({
+ emailAddress: emails.map((e) => e.toLowerCase()),
+ limit: 100,
+ });
+
+ // Access the data array directly from the paginated response
+ const data = (response as unknown as { data: unknown[] }).data;
+ if (!data) {
+ // Return nulls for all requested emails if no data
+ for (const email of emails) {
+ result.set(email, null);
+ }
+ return result;
+ }
+
+ // Build a map of email -> user
+ const userMap = new Map();
+
+ for (const user of data as {
+ emailAddresses: { emailAddress: string }[];
+ fullName: string | null;
+ firstName: string | null;
+ imageUrl: string | null;
+ }[]) {
+ for (const emailEntry of user.emailAddresses) {
+ userMap.set(emailEntry.emailAddress.toLowerCase(), user);
+ }
+ }
+
+ // Map each requested email to the result
+ for (const email of emails) {
+ const user = userMap.get(email.toLowerCase());
+ if (user) {
+ result.set(email, {
+ email: email.toLowerCase(),
+ name: user.fullName ?? user.firstName ?? null,
+ avatarUrl: user.imageUrl ?? null,
+ });
+ } else {
+ result.set(email, null);
+ }
+ }
+
+ return result;
+}
\ No newline at end of file
diff --git a/lib/project-access.ts b/lib/project-access.ts
new file mode 100644
index 0000000..b238039
--- /dev/null
+++ b/lib/project-access.ts
@@ -0,0 +1,84 @@
+import { auth, clerkClient } from "@clerk/nextjs/server";
+import { prisma } from "@/lib/prisma";
+
+/**
+ * Current user identity from Clerk session.
+ */
+export interface CurrentUser {
+ userId: string;
+ email: string;
+}
+
+/**
+ * Resolve the current user identity (userId + primary email) from the active
+ * Clerk session. Returns null if no user is signed in.
+ */
+export async function getCurrentUser(): Promise {
+ const { userId } = await auth();
+ if (!userId) {
+ return null;
+ }
+
+ // Get the user's primary email from Clerk
+ const clerk = await clerkClient();
+ const user = await clerk.users.getUser(userId);
+
+ // Find the primary email address
+ const primaryEmail = user.emailAddresses.find(
+ (email) => email.id === user.primaryEmailAddressId
+ );
+
+ const email = primaryEmail?.emailAddress ?? user.emailAddresses[0]?.emailAddress ?? "";
+
+ return {
+ userId,
+ email,
+ };
+}
+
+/**
+ * Check if the current user has access to a project (as owner or collaborator).
+ * Returns the project if access is granted, null otherwise.
+ */
+export async function getProjectWithAccess(projectId: string): Promise<{
+ id: string;
+ name: string;
+ ownerId: string;
+} | null> {
+ const currentUser = await getCurrentUser();
+ if (!currentUser) {
+ return null;
+ }
+
+ // First, check if the project exists and user is the owner
+ const project = await prisma.project.findUnique({
+ where: { id: projectId },
+ select: { id: true, name: true, ownerId: true },
+ });
+
+ if (!project) {
+ return null;
+ }
+
+ // Owner has access
+ if (project.ownerId === currentUser.userId) {
+ return project;
+ }
+
+ // Check if user is a collaborator (by email)
+ const collaborator = await prisma.projectCollaborator.findUnique({
+ where: {
+ projectId_email: {
+ projectId: project.id,
+ email: currentUser.email,
+ },
+ },
+ });
+
+ if (collaborator) {
+ return project;
+ }
+
+ // No access
+ return null;
+}
\ No newline at end of file
diff --git a/lib/projects.ts b/lib/projects.ts
index 1302bca..fee1aaa 100644
--- a/lib/projects.ts
+++ b/lib/projects.ts
@@ -26,10 +26,11 @@ export async function getOwnedProjects(userId: string): Promise {
/**
* Fetch all projects the given user has been invited to collaborate on.
* Returns the project data along with the collaborator email.
+ * @param userEmail - The user's email address to match against collaborators
*/
-export async function getSharedProjects(userId: string): Promise {
+export async function getSharedProjects(userEmail: string): Promise {
const collaborators = await prisma.projectCollaborator.findMany({
- where: { email: userId },
+ where: { email: userEmail.toLowerCase() },
include: { project: true },
orderBy: { createdAt: "desc" },
});
@@ -39,14 +40,16 @@ export async function getSharedProjects(userId: string): Promise {
/**
* Fetch both owned and shared projects for a user.
+ * @param userId - The Clerk user ID for owned projects
+ * @param userEmail - The user's email address for shared projects
*/
-export async function getAllProjects(userId: string): Promise<{
+export async function getAllProjects(userId: string, userEmail: string): Promise<{
owned: Project[];
shared: Project[];
}> {
const [owned, shared] = await Promise.all([
getOwnedProjects(userId),
- getSharedProjects(userId),
+ getSharedProjects(userEmail),
]);
return { owned, shared };