diff --git a/.gitignore b/.gitignore index 746916a..c174ff1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ node_modules coverage dist build +tsconfig.tsbuildinfo /app/generated/prisma diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index c21cf80..e10ce27 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import { Sparkles, Share2, FileText } from "lucide-react"; +import { ThemeToggle } from "@/components/theme-toggle"; + interface AuthLayoutProps { children: ReactNode; } diff --git a/app/api/invitations/[invitationId]/route.ts b/app/api/invitations/[invitationId]/route.ts index 06fa49b..7851d0b 100644 --- a/app/api/invitations/[invitationId]/route.ts +++ b/app/api/invitations/[invitationId]/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { getCurrentIdentity } from "@/lib/project-access"; +import { gateRequest } from "@/lib/rate-limit"; interface RouteContext { params: Promise<{ invitationId: string }>; @@ -18,6 +19,9 @@ export async function POST(request: Request, ctx: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`invitations:${identity.userId}`, "mutate"); + if (denied) return denied; + const { invitationId } = await ctx.params; let body: ActionBody; diff --git a/app/api/invitations/route.ts b/app/api/invitations/route.ts new file mode 100644 index 0000000..d4e31d0 --- /dev/null +++ b/app/api/invitations/route.ts @@ -0,0 +1,18 @@ +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; + +import { getProjectsForCurrentUser } from "@/lib/projects-data"; +import { gateRequest } from "@/lib/rate-limit"; + +export async function GET() { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const denied = gateRequest(`invitations:${userId}`, "list"); + if (denied) return denied; + + const { invitations } = await getProjectsForCurrentUser(); + return NextResponse.json({ invitations }); +} diff --git a/app/api/liveblocks-auth/route.ts b/app/api/liveblocks-auth/route.ts index 675703e..f092c5c 100644 --- a/app/api/liveblocks-auth/route.ts +++ b/app/api/liveblocks-auth/route.ts @@ -1,9 +1,11 @@ import { auth } from "@clerk/nextjs/server"; +import type { RoomAccesses } from "@liveblocks/node"; import { NextResponse } from "next/server"; import { getUserProfileById } from "@/lib/collaborators"; import { getCursorColorForUser, getLiveblocksClient } from "@/lib/liveblocks"; import { getProjectForAccess } from "@/lib/project-access"; +import { gateRequest } from "@/lib/rate-limit"; interface AuthRequestBody { room?: string; @@ -15,6 +17,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`liveblocks-auth:${userId}`, "liveblocks"); + if (denied) return denied; + let body: AuthRequestBody = {}; try { body = (await request.json()) as AuthRequestBody; @@ -38,25 +43,35 @@ export async function POST(request: Request) { const { project } = access; const liveblocks = getLiveblocksClient(); + // The room grant is the authoritative permission boundary: a view-only + // collaborator gets presence (cursors) but cannot mutate storage, so the + // server rejects writes even if the client UI is bypassed. + const accesses: RoomAccesses[string] = project.canEdit + ? ["room:write"] + : ["room:read", "room:presence:write"]; + const existing = await liveblocks.getRoom(project.id).catch(() => null); if (!existing) { await liveblocks.createRoom(project.id, { defaultAccesses: [], - usersAccesses: { - [userId]: ["room:write"], - }, + usersAccesses: { [userId]: accesses }, }); } else { await liveblocks.updateRoom(project.id, { - usersAccesses: { - [userId]: ["room:write"], - }, + usersAccesses: { [userId]: accesses }, }); } const profile = await getUserProfileById(userId); const color = getCursorColorForUser(userId); - const name = profile?.displayName ?? profile?.email ?? "Anonymous"; + // Prefer the account username so team members always see who is active on the + // canvas; fall back to a display name or email, and only to "Anonymous" when + // the account carries no identity at all. + const name = + profile?.username ?? + profile?.displayName ?? + profile?.email ?? + "Anonymous"; const userInfo: { name: string; avatar?: string; color: string } = { name, color, diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts new file mode 100644 index 0000000..7b33b9e --- /dev/null +++ b/app/api/notifications/route.ts @@ -0,0 +1,76 @@ +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import type { ClientNotification } from "@/lib/notifications"; +import { gateRequest } from "@/lib/rate-limit"; + +interface MarkReadBody { + id?: unknown; +} + +export async function GET() { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const denied = gateRequest(`notifications:${userId}`, "list"); + if (denied) return denied; + + const rows = await prisma.projectNotification.findMany({ + where: { userId }, + orderBy: [{ readAt: "asc" }, { createdAt: "desc" }], + take: 50, + select: { + id: true, + projectId: true, + projectName: true, + type: true, + createdAt: true, + readAt: true, + }, + }); + + const notifications: ClientNotification[] = rows.map((row) => ({ + id: row.id, + projectId: row.projectId, + projectName: row.projectName, + type: row.type as ClientNotification["type"], + createdAt: row.createdAt.toISOString(), + read: row.readAt !== null, + })); + + return NextResponse.json({ notifications }); +} + +export async function POST(request: Request) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const denied = gateRequest(`notifications:${userId}`, "mutate"); + if (denied) return denied; + + let body: MarkReadBody; + try { + body = (await request.json()) as MarkReadBody; + } catch { + return NextResponse.json({ error: "Malformed JSON" }, { status: 400 }); + } + + if (typeof body.id === "string") { + await prisma.projectNotification.updateMany({ + where: { id: body.id, userId }, + data: { readAt: new Date() }, + }); + } else { + await prisma.projectNotification.updateMany({ + where: { userId, readAt: null }, + data: { readAt: new Date() }, + }); + } + + return NextResponse.json({ success: true }); +} \ No newline at end of file diff --git a/app/api/projects/[projectId]/access/route.ts b/app/api/projects/[projectId]/access/route.ts new file mode 100644 index 0000000..2ae8792 --- /dev/null +++ b/app/api/projects/[projectId]/access/route.ts @@ -0,0 +1,47 @@ +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/prisma"; +import { getProjectForAccess } from "@/lib/project-access"; +import { gateRequest } from "@/lib/rate-limit"; + +interface RouteContext { + params: Promise<{ projectId: string }>; +} + +/** + * Lightweight real-time access probe used by the open workspace to reflect an + * owner's deletion (or a revoked membership) immediately instead of waiting for + * a reload. Distinguishes "deleted" from "denied" by checking row existence. + */ +export async function GET(_request: Request, ctx: RouteContext) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const denied = gateRequest(`projects:${userId}:access`, "read"); + if (denied) return denied; + + const { projectId } = await ctx.params; + + const result = await getProjectForAccess(projectId); + if (result.kind === "unauthenticated") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (result.kind === "ok") { + return NextResponse.json({ ok: true }); + } + + // The caller lost access — is it because the workspace was deleted, or because + // the membership is gone while the project still exists? + const exists = await prisma.project.findUnique({ + where: { id: projectId }, + select: { id: true }, + }); + + return NextResponse.json({ + ok: false, + reason: exists ? "denied" : "deleted", + }); +} \ No newline at end of file diff --git a/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts b/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts index 3f153e3..59f8567 100644 --- a/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts +++ b/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts @@ -1,8 +1,11 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/client"; import { NextResponse } from "next/server"; +import { getUserIdByEmail } from "@/lib/collaborators"; +import { getLiveblocksClient } from "@/lib/liveblocks"; import { prisma } from "@/lib/prisma"; import { getCurrentIdentity } from "@/lib/project-access"; +import { gateRequest } from "@/lib/rate-limit"; interface RouteContext { params: Promise<{ projectId: string; collaboratorId: string }>; @@ -10,6 +13,7 @@ interface RouteContext { interface PatchBody { canShare?: unknown; + canEdit?: unknown; } export async function PATCH(request: Request, ctx: RouteContext) { @@ -18,6 +22,9 @@ export async function PATCH(request: Request, ctx: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`collaborators:${identity.userId}`, "mutate"); + if (denied) return denied; + const { projectId, collaboratorId } = await ctx.params; let body: PatchBody; @@ -27,12 +34,28 @@ export async function PATCH(request: Request, ctx: RouteContext) { return NextResponse.json({ error: "Malformed JSON" }, { status: 400 }); } - if (typeof body.canShare !== "boolean") { + if (body.canShare !== undefined && typeof body.canShare !== "boolean") { return NextResponse.json( { error: "`canShare` must be a boolean" }, { status: 400 }, ); } + if (body.canEdit !== undefined && typeof body.canEdit !== "boolean") { + return NextResponse.json( + { error: "`canEdit` must be a boolean" }, + { status: 400 }, + ); + } + + const data: { canShare?: boolean; canEdit?: boolean } = {}; + if (typeof body.canShare === "boolean") data.canShare = body.canShare; + if (typeof body.canEdit === "boolean") data.canEdit = body.canEdit; + if (Object.keys(data).length === 0) { + return NextResponse.json( + { error: "Provide `canShare` and/or `canEdit`" }, + { status: 400 }, + ); + } const project = await prisma.project.findUnique({ where: { id: projectId }, @@ -48,9 +71,21 @@ export async function PATCH(request: Request, ctx: RouteContext) { try { const updated = await prisma.projectCollaborator.update({ where: { id: collaboratorId }, - data: { canShare: body.canShare }, - select: { id: true, email: true, status: true, canShare: true }, + data, + select: { + id: true, + email: true, + status: true, + canShare: true, + canEdit: true, + }, }); + // Push the new grant into the room now, so a revoked collaborator loses + // write access on their current connection instead of at token expiry. + if (typeof data.canEdit === "boolean") { + await syncRoomAccess(projectId, updated.email, data.canEdit); + } + return NextResponse.json({ collaborator: updated }); } catch (error) { if ( @@ -62,3 +97,29 @@ export async function PATCH(request: Request, ctx: RouteContext) { throw error; } } + +async function syncRoomAccess( + projectId: string, + email: string, + canEdit: boolean, +): Promise { + try { + const collaboratorUserId = await getUserIdByEmail(email); + if (!collaboratorUserId) return; + + const liveblocks = getLiveblocksClient(); + const room = await liveblocks.getRoom(projectId).catch(() => null); + if (!room) return; + + await liveblocks.updateRoom(projectId, { + usersAccesses: { + [collaboratorUserId]: canEdit + ? ["room:write"] + : ["room:read", "room:presence:write"], + }, + }); + } catch { + // Non-fatal: the database is the source of truth and liveblocks-auth + // re-derives access on the collaborator's next connection. + } +} diff --git a/app/api/projects/[projectId]/collaborators/route.ts b/app/api/projects/[projectId]/collaborators/route.ts index da7cacf..ef56ea4 100644 --- a/app/api/projects/[projectId]/collaborators/route.ts +++ b/app/api/projects/[projectId]/collaborators/route.ts @@ -4,6 +4,7 @@ import { NextResponse } from "next/server"; import { enrichCollaborators, getUserProfileById } from "@/lib/collaborators"; import { prisma } from "@/lib/prisma"; import { getCurrentIdentity } from "@/lib/project-access"; +import { gateRequest } from "@/lib/rate-limit"; interface RouteContext { params: Promise<{ projectId: string }>; @@ -18,6 +19,7 @@ interface PermissionContext { email: string; status: "PENDING" | "ACTIVE"; canShare: boolean; + canEdit: boolean; } | null; } @@ -46,7 +48,13 @@ async function resolvePermissions( if (!isOwner && identity.emails.length > 0) { const match = await prisma.projectCollaborator.findFirst({ where: { projectId, email: { in: identity.emails } }, - select: { id: true, email: true, status: true, canShare: true }, + select: { + id: true, + email: true, + status: true, + canShare: true, + canEdit: true, + }, }); if (match) { callerCollaborator = { @@ -54,6 +62,7 @@ async function resolvePermissions( email: match.email, status: match.status, canShare: match.canShare, + canEdit: match.canEdit, }; } } @@ -67,6 +76,9 @@ export async function GET(_request: Request, ctx: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`collaborators:${identity.userId}`, "read"); + if (denied) return denied; + const { projectId } = await ctx.params; const { project, permissions } = await resolvePermissions( projectId, @@ -91,6 +103,7 @@ export async function GET(_request: Request, ctx: RouteContext) { email: true, status: true, canShare: true, + canEdit: true, }, }); @@ -104,6 +117,7 @@ export async function GET(_request: Request, ctx: RouteContext) { email: row.email, status: row.status, canShare: row.canShare, + canEdit: row.canEdit, displayName: profiles[index].displayName, avatarUrl: profiles[index].avatarUrl, })); @@ -115,11 +129,14 @@ export async function GET(_request: Request, ctx: RouteContext) { canShare: permissions.isOwner || Boolean(permissions.callerCollaborator?.canShare), + canEdit: + permissions.isOwner || Boolean(permissions.callerCollaborator?.canEdit), }); } interface InviteBody { email?: unknown; + canEdit?: unknown; } export async function POST(request: Request, ctx: RouteContext) { @@ -128,6 +145,9 @@ export async function POST(request: Request, ctx: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`collaborators:${identity.userId}`, "mutate"); + if (denied) return denied; + const { projectId } = await ctx.params; let body: InviteBody; @@ -146,6 +166,15 @@ export async function POST(request: Request, ctx: RouteContext) { ); } + if (body.canEdit !== undefined && typeof body.canEdit !== "boolean") { + return NextResponse.json( + { error: "`canEdit` must be a boolean" }, + { status: 400 }, + ); + } + // Default to view-only so an invite never silently confers write access. + const requestedCanEdit = body.canEdit === true; + const { project, permissions } = await resolvePermissions( projectId, identity, @@ -171,8 +200,22 @@ export async function POST(request: Request, ctx: RouteContext) { try { const created = await prisma.projectCollaborator.create({ - data: { projectId, email, status: "PENDING", canShare: false }, - select: { id: true, email: true, status: true, canShare: true }, + data: { + projectId, + email, + status: "PENDING", + canShare: false, + // Only an owner may hand out edit access at invite time; a + // collaborator with share rights can invite view-only people. + canEdit: permissions.isOwner ? requestedCanEdit : false, + }, + select: { + id: true, + email: true, + status: true, + canShare: true, + canEdit: true, + }, }); const [profile] = await enrichCollaborators([email]); @@ -183,6 +226,7 @@ export async function POST(request: Request, ctx: RouteContext) { email: created.email, status: created.status, canShare: created.canShare, + canEdit: created.canEdit, displayName: profile.displayName, avatarUrl: profile.avatarUrl, }, @@ -213,6 +257,9 @@ export async function DELETE(request: Request, ctx: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`collaborators:${identity.userId}`, "mutate"); + if (denied) return denied; + const { projectId } = await ctx.params; let body: RemoveBody; diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index 0252bc1..209d891 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -1,7 +1,10 @@ import { auth } from "@clerk/nextjs/server"; import { revalidatePath } from "next/cache"; import { NextResponse } from "next/server"; +import { getUserIdByEmail } from "@/lib/collaborators"; import { prisma } from "@/lib/prisma"; +import { getLiveblocksClient } from "@/lib/liveblocks"; +import { gateRequest } from "@/lib/rate-limit"; interface RouteContext { params: Promise<{ projectId: string }>; @@ -17,6 +20,9 @@ export async function PATCH(request: Request, ctx: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`projects:${userId}`, "mutate"); + if (denied) return denied; + const { projectId } = await ctx.params; let body: UpdateProjectBody; @@ -51,7 +57,7 @@ export async function PATCH(request: Request, ctx: RouteContext) { data: { name: rawName }, }); - revalidatePath("/editor"); + revalidatePath("/editor", "layout"); return NextResponse.json({ project: updated }); } @@ -61,19 +67,63 @@ export async function DELETE(_request: Request, ctx: RouteContext) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`projects:${userId}`, "mutate"); + if (denied) return denied; + const { projectId } = await ctx.params; const project = await prisma.project.findUnique({ where: { id: projectId }, - select: { ownerId: true }, + select: { + ownerId: true, + name: true, + collaborators: { + where: { status: "ACTIVE" }, + select: { email: true }, + }, + }, }); if (!project || project.ownerId !== userId) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } + // Leave a "workspace deleted" notification for every active collaborator so + // they learn about it the next time they sign in — not just those with the + // workspace open right now. Best effort: a notification failure must never + // block the deletion itself. + try { + const recipients = new Set(); + for (const collaborator of project.collaborators) { + if (!collaborator.email) continue; + const recipientId = await getUserIdByEmail(collaborator.email); + if (recipientId && recipientId !== project.ownerId) { + recipients.add(recipientId); + } + } + await prisma.projectNotification.createMany({ + data: Array.from(recipients).map((recipientId) => ({ + userId: recipientId, + projectId, + projectName: project.name, + type: "PROJECT_DELETED", + })), + }); + } catch { + // Notification creation is best-effort. + } + await prisma.project.delete({ where: { id: projectId } }); - revalidatePath("/editor"); + // Tear down the collaboration room so connected collaborators lose their live + // connection too (their open canvas then flips to the deleted notice). Best + // effort — a Liveblocks outage must not block the project deletion itself. + try { + await getLiveblocksClient().deleteRoom(projectId); + } catch { + // Room cleanup is best-effort; the DB row is already gone. + } + + revalidatePath("/editor", "layout"); return NextResponse.json({ success: true }); } diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 68055c8..42b0de0 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -3,6 +3,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/client"; import { revalidatePath } from "next/cache"; import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; +import { gateRequest } from "@/lib/rate-limit"; export async function GET() { const { userId } = await auth(); @@ -10,6 +11,9 @@ export async function GET() { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`projects:${userId}`, "read"); + if (denied) return denied; + const projects = await prisma.project.findMany({ where: { ownerId: userId }, orderBy: { createdAt: "desc" }, @@ -32,6 +36,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + const denied = gateRequest(`projects:${userId}`, "mutate"); + if (denied) return denied; + let body: CreateProjectBody; try { body = (await request.json()) as CreateProjectBody; @@ -70,7 +77,7 @@ export async function POST(request: Request) { description, }, }); - revalidatePath("/editor"); + revalidatePath("/editor", "layout"); return NextResponse.json({ project }, { status: 201 }); } catch (error) { if ( diff --git a/app/api/projects/summary/route.ts b/app/api/projects/summary/route.ts new file mode 100644 index 0000000..e070437 --- /dev/null +++ b/app/api/projects/summary/route.ts @@ -0,0 +1,18 @@ +import { auth } from "@clerk/nextjs/server"; +import { NextResponse } from "next/server"; + +import { getProjectsForCurrentUser } from "@/lib/projects-data"; +import { gateRequest } from "@/lib/rate-limit"; + +export async function GET() { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const denied = gateRequest(`projects:${userId}:summary`, "list"); + if (denied) return denied; + + const { owned, shared } = await getProjectsForCurrentUser(); + return NextResponse.json({ owned, shared }); +} \ No newline at end of file diff --git a/app/editor/[roomId]/page.tsx b/app/editor/[roomId]/page.tsx index 33e5cd9..807ff30 100644 --- a/app/editor/[roomId]/page.tsx +++ b/app/editor/[roomId]/page.tsx @@ -26,6 +26,7 @@ export default async function EditorRoomPage({ params }: EditorRoomPageProps) { id: result.project.id, name: result.project.name, ownedByCurrentUser: result.project.ownedByCurrentUser, + canEdit: result.project.canEdit, }} /> ); diff --git a/app/editor/error.tsx b/app/editor/error.tsx new file mode 100644 index 0000000..31c2128 --- /dev/null +++ b/app/editor/error.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect } from "react"; + +import { Button } from "@/components/ui/button"; + +export default function EditorError({ + error, +}: { + error: Error & { digest?: string }; +}) { + useEffect(() => { + console.error("Editor error:", error); + }, [error]); + + return ( +
+
+

+ Something went wrong +

+

+ The workspace failed to load. Try again, or refresh the page. +

+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/app/editor/layout.tsx b/app/editor/layout.tsx index f596a3b..1294fd3 100644 --- a/app/editor/layout.tsx +++ b/app/editor/layout.tsx @@ -2,15 +2,34 @@ import type { ReactNode } from "react"; import { EditorChrome } from "@/components/editor/editor-chrome"; import { getProjectsForCurrentUser } from "@/lib/projects-data"; +import type { PendingInvitation } from "@/lib/projects-data"; +import type { ProjectSummary } from "@/lib/projects"; interface EditorLayoutProps { children: ReactNode; } export default async function EditorLayout({ children }: EditorLayoutProps) { - const { owned, shared, invitations } = await getProjectsForCurrentUser(); + let lists: { + owned: ProjectSummary[]; + shared: ProjectSummary[]; + invitations: PendingInvitation[]; + } = { owned: [], shared: [], invitations: [] }; + try { + // Right after a credentials sign-in the very first RSC navigation can race + // the freshly-committed session cookie. Don't let that take the whole layout + // down (blank screen) — fall back to empty lists; the client-side live hooks + // hydrate them moments later. + lists = await getProjectsForCurrentUser(); + } catch { + // Best-effort: transient auth/data failures poll in once live hooks connect. + } return ( - + {children} ); diff --git a/app/globals.css b/app/globals.css index 204480c..92ab9fb 100644 --- a/app/globals.css +++ b/app/globals.css @@ -122,6 +122,20 @@ --state-error: #ff4d4f; --state-success: #34d399; --state-warning: #fbbf24; + --grid-dot: rgba(255, 255, 255, 0.08); + + /* Canvas-specific tokens (dark theme) */ + --canvas-bg: #0a0a12; + --canvas-dot: rgba(255, 255, 255, 0.28); + --canvas-vignette: rgba(0, 0, 0, 0.45); + --canvas-shape-fill: rgba(20, 20, 28, 0.85); + --canvas-handle-ring: rgba(8, 8, 12, 0.9); + --canvas-handle-border: rgba(0, 200, 212, 0.9); + --canvas-resize-border: rgba(0, 200, 212, 0.8); + --canvas-avatar-ring: #0a0a12; + --canvas-overflow-bg: #1b1b25; + + color-scheme: dark; } @layer base { @@ -143,3 +157,93 @@ display: none; } } + +/* Dot-matrix backdrop for the landing page (draws with the theme accent). */ +@utility bg-grid { + background-image: radial-gradient( + circle, + var(--grid-dot) 1px, + transparent 1px + ); + background-size: 28px 28px; +} + +@media (prefers-reduced-motion: reduce) { + .landing-reveal { + transition: none !important; + transform: none !important; + opacity: 1 !important; + } +} + +/* Global light theme. Setting these on `html.light` re-themes the whole app + through the token system: every surface/text/border/accent utility resolves + against these custom properties, so components switch automatically. The + editor canvas keeps its intentionally dark drawing surface (node fills, + glows) in both themes; the UI chrome around it adapts. */ +html.light { + --grid-dot: rgba(15, 17, 30, 0.08); + --bg-base: #f6f7f9; + --bg-surface: #ffffff; + --bg-elevated: #eef0f4; + --bg-subtle: #e4e7ee; + --border-default: #e2e5ed; + --border-subtle: #cdd2df; + --text-primary: #0f1119; + --text-secondary: #2c3242; + --text-muted: #5b6478; + --text-faint: #97a0b3; + --accent-primary: #009ba5; + --accent-primary-dim: rgba(0, 155, 165, 0.12); + --accent-ai: #6d5cf6; + --accent-ai-text: #5647ec; + --state-error: #e5484d; + --state-success: #0f9d6f; + --state-warning: #b5840a; + + /* shadcn component tokens (light values) */ + --background: oklch(0.985 0.003 260); + --foreground: oklch(0.18 0.01 260); + --card: oklch(1 0 0); + --card-foreground: oklch(0.18 0.01 260); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.18 0.01 260); + --primary: oklch(0.18 0.01 260); + --primary-foreground: oklch(0.98 0 0); + --secondary: oklch(0.94 0.005 260); + --secondary-foreground: oklch(0.28 0.01 260); + --muted: oklch(0.95 0.005 260); + --muted-foreground: oklch(0.52 0.01 260); + --accent: oklch(0.94 0.005 260); + --accent-foreground: oklch(0.28 0.01 260); + --destructive: oklch(0.55 0.2 25); + --border: oklch(0.89 0.005 260); + --input: oklch(0.87 0.005 260); + --ring: oklch(0.75 0.14 195); + --chart-1: oklch(0.6 0.14 195); + --chart-2: oklch(0.6 0.18 270); + --chart-3: oklch(0.6 0.18 30); + --chart-4: oklch(0.6 0.15 150); + --chart-5: oklch(0.6 0.2 0); + --sidebar: oklch(0.985 0.003 260); + --sidebar-foreground: oklch(0.2 0.01 260); + --sidebar-primary: oklch(0.55 0.14 195); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.94 0.005 260); + --sidebar-accent-foreground: oklch(0.28 0.01 260); + --sidebar-border: oklch(0.91 0.004 260); + --sidebar-ring: oklch(0.8 0.02 250); + + /* Canvas-specific tokens (light theme) */ + --canvas-bg: #e8eaef; + --canvas-dot: rgba(0, 0, 0, 0.12); + --canvas-vignette: rgba(0, 0, 0, 0.18); + --canvas-shape-fill: rgba(240, 241, 244, 0.92); + --canvas-handle-ring: rgba(220, 222, 228, 0.9); + --canvas-handle-border: rgba(0, 155, 165, 0.9); + --canvas-resize-border: rgba(0, 155, 165, 0.8); + --canvas-avatar-ring: #e8eaef; + --canvas-overflow-bg: #d8dae2; + + color-scheme: light; +} diff --git a/app/layout.tsx b/app/layout.tsx index d07fa9b..b725850 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; -import { ClerkProvider } from "@clerk/nextjs"; -import { dark } from "@clerk/ui/themes"; + +import { ThemeProvider } from "@/components/theme"; +import { ThemedClerkAppearance } from "@/components/themed-clerk-appearance"; import "./globals.css"; @@ -26,32 +27,15 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} - - + + + + {children} + + + ); -} +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index dba18eb..67acf07 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,38 @@ -import { redirect } from "next/navigation"; import { auth } from "@clerk/nextjs/server"; +import { CanvasShowcase } from "@/components/landing/canvas-showcase"; +import { CtaBand } from "@/components/landing/cta-band"; +import { Faq } from "@/components/landing/faq"; +import { Features } from "@/components/landing/features"; +import { Hero } from "@/components/landing/hero"; +import { HowItWorks } from "@/components/landing/how-it-works"; +import { LandingFooter } from "@/components/landing/landing-footer"; +import { LandingNavbar } from "@/components/landing/landing-navbar"; +import { SpecShowcase } from "@/components/landing/spec-showcase"; + +export const metadata = { + title: "Emedit AI | Collaborative system design at the speed of thought", + description: + "Describe your architecture in plain English, refine it together on a real-time canvas, and export a polished technical spec.", +}; + export default async function Home() { const { userId } = await auth(); - redirect(userId ? "/editor" : "/sign-in"); + const signedIn = Boolean(userId); + + return ( + <> + +
+ + + + + + + +
+ + + ); } diff --git a/components/editor/canvas-controls.tsx b/components/editor/canvas-controls.tsx new file mode 100644 index 0000000..dc4e7c9 --- /dev/null +++ b/components/editor/canvas-controls.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useReactFlow } from "@xyflow/react"; +import { Maximize2, Minus, Plus, Redo2, Undo2 } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +interface CanvasControlsProps { + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; +} + +interface ControlButtonProps { + onClick: () => void; + title: string; + icon: LucideIcon; + disabled?: boolean; +} + +function ControlButton({ + onClick, + title, + icon: Icon, + disabled = false, +}: ControlButtonProps) { + return ( + + ); +} + +const ZOOM_DURATION = 200; + +/** + * Pill-shaped floating control bar pinned to the bottom-left of the canvas, + * above the shape panel. Left group: zoom out / fit view / zoom in. Right + * group (after a divider): undo / redo. Undo and redo drive the collaborative + * Liveblocks history and dim when there is nothing to undo/redo. + */ +export function CanvasControls({ + canUndo, + canRedo, + onUndo, + onRedo, +}: CanvasControlsProps) { + const { zoomIn, zoomOut, fitView } = useReactFlow(); + + return ( +
+
+ zoomOut({ duration: ZOOM_DURATION })} + title="Zoom out (−)" + icon={Minus} + /> + fitView({ duration: ZOOM_DURATION + 50 })} + title="Fit view" + icon={Maximize2} + /> + zoomIn({ duration: ZOOM_DURATION })} + title="Zoom in (+)" + icon={Plus} + /> + + + +
+
+ ); +} \ No newline at end of file diff --git a/components/editor/canvas-edge.tsx b/components/editor/canvas-edge.tsx new file mode 100644 index 0000000..2b6d91b --- /dev/null +++ b/components/editor/canvas-edge.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { + BaseEdge, + EdgeLabelRenderer, + getBezierPath, + getSmoothStepPath, + Position, + type EdgeProps, +} from "@xyflow/react"; +import { useState, type KeyboardEvent } from "react"; + +import type { CanvasEdge } from "@/types/canvas"; + +const isVerticalPosition = (position: Position | undefined) => + position === Position.Top || position === Position.Bottom; + +interface CanvasEdgeComponentProps extends EdgeProps { + isEditing?: boolean; + onStartEdit?: () => void; + onCommitLabel?: (label: string) => void; +} + +/** + * Custom edge that renders its label with `EdgeLabelRenderer` anchored to the + * midpoint of the path. The label lives on `edge.data.label` and is edited + * inline; changes flow back through the collaborative replace-change path. + */ +export function CanvasEdgeComponent({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + selected, + isEditing = false, + data, + onStartEdit, + onCommitLabel, +}: CanvasEdgeComponentProps) { + const [edgePath, labelX, labelY] = + isVerticalPosition(sourcePosition) && isVerticalPosition(targetPosition) + ? getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 8, + }) + : getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const [draft, setDraft] = useState(null); + const label = data?.label ?? ""; + + const commit = () => { + onCommitLabel?.((draft ?? label).trim()); + setDraft(null); + }; + + return ( + <> + + +
+ {isEditing ? ( + setDraft(event.target.value)} + onBlur={commit} + onKeyDown={(event: KeyboardEvent) => { + if (event.key === "Enter") { + commit(); + event.currentTarget.blur(); + } else if (event.key === "Escape") { + setDraft(null); + event.currentTarget.blur(); + } + }} + className="pointer-events-auto nopan nodrag h-6 min-w-[6rem] rounded-full border border-brand bg-surface/95 px-2 text-[11px] text-copy-primary outline-none focus:border-brand" + /> + ) : label ? ( + + {label} + + ) : selected ? ( + + ) : null} +
+
+ + ); +} \ No newline at end of file diff --git a/components/editor/canvas-node.tsx b/components/editor/canvas-node.tsx index c510a01..bf2076f 100644 --- a/components/editor/canvas-node.tsx +++ b/components/editor/canvas-node.tsx @@ -1,68 +1,145 @@ "use client"; +import { Handle, NodeResizer, Position, type NodeProps } from "@xyflow/react"; +import { Trash2 } from "lucide-react"; import { - Handle, - NodeResizer, - NodeToolbar, - Position, - type NodeProps, -} from "@xyflow/react"; -import { useState, type KeyboardEvent, type MouseEvent } from "react"; + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, +} from "react"; -import type { CanvasNode } from "@/types/canvas"; -import { CANVAS_FONTS, DEFAULT_FONT_KEY, fontCssVar } from "./canvas-fonts"; +import type { CanvasNode, NodeColorPair } from "@/types/canvas"; +import { DEFAULT_FONT_KEY, fontCssVar } from "./canvas-fonts"; +import { NodeStyleToolbar } from "./node-style-toolbar"; import { ShapeOutline } from "./shape-outline"; const MIN_NODE_WIDTH = 60; const MIN_NODE_HEIGHT = 40; -const HANDLE_COLOR = "rgba(0, 200, 212, 0.9)"; -const RESIZE_HANDLE_COLOR = "rgba(0, 200, 212, 0.8)"; +const HANDLE_COLOR = "var(--canvas-handle-border)"; +const RESIZE_HANDLE_COLOR = "var(--canvas-resize-border)"; +const HANDLE_DOT = 7; +const HANDLE_HIT = 16; const FONT_SLOPE = 1 / 11; -const FONT_SIZE_MIN = 8; -const FONT_SIZE_MAX = 96; const scaleFont = (width: number) => Math.max(9, Math.round(width * FONT_SLOPE)); +/** + * Handles stay invisible until the node is selected, but keep their pointer + * events so a connection can still be dragged off an unselected node. The + * element is a 16px grab target with the visible 7px dot painted by a radial + * gradient — a 7px hit area is far too small to reliably start a connection. + */ +const handleStyle = (selected: boolean) => ({ + width: HANDLE_HIT, + height: HANDLE_HIT, + minWidth: HANDLE_HIT, + minHeight: HANDLE_HIT, + border: "none", + borderRadius: "9999px", + background: `radial-gradient(circle at center, ${HANDLE_COLOR} 0 ${ + HANDLE_DOT / 2 + }px, var(--canvas-handle-ring) ${HANDLE_DOT / 2}px ${ + HANDLE_DOT / 2 + 1 + }px, transparent ${HANDLE_DOT / 2 + 1}px)`, + opacity: selected ? 1 : 0, + transition: "opacity 150ms ease", +}); + interface CanvasNodeRendererProps extends NodeProps { isEditing?: boolean; onStartEdit?: (id: string, label: string) => void; onChangeLabel?: (id: string, label: string) => void; onChangeFont?: (id: string, font: string) => void; onChangeFontSize?: (id: string, fontSize: number) => void; + onChangeColor?: (id: string, pair: NodeColorPair) => void; onEndEdit?: (id: string, label: string) => void; + onDeleteNode?: (id: string) => void; + onAutoSize?: (id: string, size: { width: number; height: number }) => void; } +/** Fixed base font for text annotations until the user sets one explicitly. */ +const TEXT_BASE_FONT_SIZE = 16; + export function CanvasNodeRenderer({ id, data, width, + height, selected, isEditing = false, onStartEdit, onChangeLabel, onChangeFont, onChangeFontSize, + onChangeColor, onEndEdit, + onDeleteNode, + onAutoSize, }: CanvasNodeRendererProps) { const isTextNode = data.shape === "text"; - const autoFontSize = scaleFont(width ?? 0); + const measureRef = useRef(null); + const textareaRef = useRef(null); + // Text nodes size to their content instead of staying rigid, so raising the + // font size spreads the box out rather than clipping the text inward. + const autoFontSize = isTextNode ? TEXT_BASE_FONT_SIZE : scaleFont(width ?? 0); const fontSize = data.fontSize ?? autoFontSize; const fontFamily = fontCssVar(data.font ?? DEFAULT_FONT_KEY); - const textColor = isTextNode ? data.color : undefined; + const textColor = data.color; + + useLayoutEffect(() => { + if (!isTextNode || !onAutoSize) return; + const el = measureRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const naturalW = Math.ceil(rect.width) + 24; + const naturalH = Math.ceil(rect.height) + 4; + const nextW = Math.max(width ?? 0, naturalW); + const nextH = Math.max(height ?? 0, naturalH); + if (nextW !== width || nextH !== height) { + onAutoSize(id, { width: nextW, height: nextH }); + } + }, [isTextNode, onAutoSize, id, data.label, data.font, fontSize, width, height]); - const [sizeDraft, setSizeDraft] = useState(null); - const sizeValue = sizeDraft ?? String(fontSize); + // Keep the inline edit box hugging its own text so the flex centering in the + // wrapper holds the caret on the same line as the static label — without this + // the textarea is several lines tall, its text sits top-aligned, and the text + // visibly jumps up during editing. + useLayoutEffect(() => { + const el = textareaRef.current; + if (!isEditing || !el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [isEditing, data.label, fontSize]); - const commitFontSize = (draft: string) => { - const trimmed = draft.trim(); - const parsed = trimmed === "" ? Number.NaN : Number(trimmed); - const next = Number.isFinite(parsed) - ? Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, parsed)) - : autoFontSize; - onChangeFontSize?.(id, next); - setSizeDraft(null); + const [hovered, setHovered] = useState(false); + const hoverTimerRef = useRef(null); + const showHover = () => { + if (hoverTimerRef.current !== null) { + window.clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setHovered(true); + }; + const hideHover = () => { + if (hoverTimerRef.current !== null) window.clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = window.setTimeout(() => setHovered(false), 160); }; + const showDelete = selected || hovered; + const showHandles = selected || hovered; + + const handles: Array<{ + id: string; + type: "source" | "target"; + position: Position; + }> = [ + { id: "handle-top", type: "target", position: Position.Top }, + { id: "handle-left", type: "target", position: Position.Left }, + { id: "handle-bottom", type: "source", position: Position.Bottom }, + { id: "handle-right", type: "source", position: Position.Right }, + ]; const startLabelEdit = (event: MouseEvent) => { event.preventDefault(); @@ -82,6 +159,8 @@ export function CanvasNodeRenderer({
{!isTextNode ? ( - <> - - - - + ) : null}
{data.label}
+ {isTextNode ? ( + + {data.label} + + ) : null} {isEditing ? (