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 */} +
    + +
    +
    + + +
    + +
    +
    + + {/* Invite section - only for owners */} + {isOwner && ( +
    + +
    { + e.preventDefault(); + onInvite(); + }} + > + onInviteEmailChange(e.target.value)} + disabled={isLoading} + className="flex-1" + /> + +
    + {error && ( +

    + {error} +

    + )} +
    + )} + + {/* Collaborators list */} +
    + + {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 && ( + + )} +
    • + ))} +
    + )} +
    + + {/* 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 ( +
    +
    + + {projectName ? ( + + {projectName} + + ) : ( + Editor + )} +
    +
    + + +
    +
    + +
    +
    + ); +} + +interface ProjectSidebarProps { + isOpen: boolean; + onClose: () => void; + ownedProjects: ProjectData[]; + sharedProjects: ProjectData[]; + currentRoomId: string; +} + +function ProjectSidebar({ + isOpen, + onClose, + ownedProjects, + sharedProjects, + currentRoomId, +}: ProjectSidebarProps) { + return ( + <> + {/* Mobile backdrop */} + +
    + + +
    + + + My Projects + + + Shared + + +
    + + + + + + + + + +
    + +
    + +
    + + + ); +} + +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 */} + + +
    +

    AI chat coming soon

    +
    + + + ); +} \ 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 `