From c9ae138c11eb9dfa76cadbe4f3de015d69e2af28 Mon Sep 17 00:00:00 2001 From: eitighis Date: Wed, 19 Aug 2026 16:52:23 +0100 Subject: [PATCH 1/4] added liveblocks live verification , ui changes --- app/api/liveblocks-auth/route.ts | 16 +- .../collaborators/[collaboratorId]/route.ts | 63 ++- .../[projectId]/collaborators/route.ts | 43 +- app/api/projects/[projectId]/route.ts | 4 +- app/api/projects/route.ts | 2 +- app/editor/[roomId]/page.tsx | 1 + components/editor/canvas-controls.tsx | 91 ++++ components/editor/canvas-edge.tsx | 125 ++++++ components/editor/canvas-node.tsx | 232 ++++++----- components/editor/canvas.tsx | 219 +++++++++- components/editor/color-swatches.tsx | 53 +++ components/editor/editor-navbar.tsx | 16 + components/editor/font-select.tsx | 100 +++++ components/editor/node-style-toolbar.tsx | 93 +++++ components/editor/presence-overlay.tsx | 127 ++++++ components/editor/shape-outline.tsx | 42 +- components/editor/share-dialog.tsx | 115 +++++- components/editor/small-screen-gate.tsx | 73 ++++ components/editor/starter-templates-modal.tsx | 389 ++++++++++++++++++ components/editor/starter-templates.ts | 372 +++++++++++++++++ components/editor/template-import.ts | 120 ++++++ components/editor/workspace-context.tsx | 20 + components/editor/workspace-shell.tsx | 11 +- context/architecture-context.md | 23 ++ context/progress-tracker.md | 152 ++++++- context/ui-context.md | 12 +- hooks/use-keyboard-shortcuts.ts | 88 ++++ hooks/use-project-actions.ts | 6 +- lib/collaborators.ts | 18 + lib/project-access.ts | 9 +- liveblocks.config.ts | 2 +- .../migration.sql | 8 + prisma/models/project.prisma | 1 + tsconfig.tsbuildinfo | 2 +- types/canvas.ts | 34 +- 35 files changed, 2492 insertions(+), 190 deletions(-) create mode 100644 components/editor/canvas-controls.tsx create mode 100644 components/editor/canvas-edge.tsx create mode 100644 components/editor/color-swatches.tsx create mode 100644 components/editor/font-select.tsx create mode 100644 components/editor/node-style-toolbar.tsx create mode 100644 components/editor/presence-overlay.tsx create mode 100644 components/editor/small-screen-gate.tsx create mode 100644 components/editor/starter-templates-modal.tsx create mode 100644 components/editor/starter-templates.ts create mode 100644 components/editor/template-import.ts create mode 100644 hooks/use-keyboard-shortcuts.ts create mode 100644 prisma/migrations/20260819160000_add_collaborator_can_edit/migration.sql diff --git a/app/api/liveblocks-auth/route.ts b/app/api/liveblocks-auth/route.ts index 675703e..a8856ce 100644 --- a/app/api/liveblocks-auth/route.ts +++ b/app/api/liveblocks-auth/route.ts @@ -1,4 +1,5 @@ import { auth } from "@clerk/nextjs/server"; +import type { RoomAccesses } from "@liveblocks/node"; import { NextResponse } from "next/server"; import { getUserProfileById } from "@/lib/collaborators"; @@ -38,19 +39,22 @@ 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 }, }); } diff --git a/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts b/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts index 3f153e3..391774b 100644 --- a/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts +++ b/app/api/projects/[projectId]/collaborators/[collaboratorId]/route.ts @@ -1,6 +1,8 @@ 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"; @@ -10,6 +12,7 @@ interface RouteContext { interface PatchBody { canShare?: unknown; + canEdit?: unknown; } export async function PATCH(request: Request, ctx: RouteContext) { @@ -27,12 +30,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 +67,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 +93,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..fb0ab50 100644 --- a/app/api/projects/[projectId]/collaborators/route.ts +++ b/app/api/projects/[projectId]/collaborators/route.ts @@ -18,6 +18,7 @@ interface PermissionContext { email: string; status: "PENDING" | "ACTIVE"; canShare: boolean; + canEdit: boolean; } | null; } @@ -46,7 +47,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 +61,7 @@ async function resolvePermissions( email: match.email, status: match.status, canShare: match.canShare, + canEdit: match.canEdit, }; } } @@ -91,6 +99,7 @@ export async function GET(_request: Request, ctx: RouteContext) { email: true, status: true, canShare: true, + canEdit: true, }, }); @@ -104,6 +113,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 +125,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) { @@ -146,6 +159,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 +193,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 +219,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, }, diff --git a/app/api/projects/[projectId]/route.ts b/app/api/projects/[projectId]/route.ts index 0252bc1..b5e57fc 100644 --- a/app/api/projects/[projectId]/route.ts +++ b/app/api/projects/[projectId]/route.ts @@ -51,7 +51,7 @@ export async function PATCH(request: Request, ctx: RouteContext) { data: { name: rawName }, }); - revalidatePath("/editor"); + revalidatePath("/editor", "layout"); return NextResponse.json({ project: updated }); } @@ -74,6 +74,6 @@ export async function DELETE(_request: Request, ctx: RouteContext) { await prisma.project.delete({ where: { id: projectId } }); - revalidatePath("/editor"); + revalidatePath("/editor", "layout"); return NextResponse.json({ success: true }); } diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 68055c8..115ac58 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -70,7 +70,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/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/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..13faa8d --- /dev/null +++ b/components/editor/canvas-edge.tsx @@ -0,0 +1,125 @@ +"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..7d8fb03 100644 --- a/components/editor/canvas-node.tsx +++ b/components/editor/canvas-node.tsx @@ -1,36 +1,56 @@ "use client"; -import { - Handle, - NodeResizer, - NodeToolbar, - Position, - type NodeProps, -} from "@xyflow/react"; -import { useState, type KeyboardEvent, type MouseEvent } from "react"; +import { Handle, NodeResizer, Position, type NodeProps } from "@xyflow/react"; +import { Trash2 } from "lucide-react"; +import { 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_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, rgba(8, 8, 12, 0.9) ${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; } export function CanvasNodeRenderer({ @@ -43,26 +63,42 @@ export function CanvasNodeRenderer({ onChangeLabel, onChangeFont, onChangeFontSize, + onChangeColor, onEndEdit, + onDeleteNode, }: CanvasNodeRendererProps) { const isTextNode = data.shape === "text"; const autoFontSize = scaleFont(width ?? 0); const fontSize = data.fontSize ?? autoFontSize; const fontFamily = fontCssVar(data.font ?? DEFAULT_FONT_KEY); - const textColor = isTextNode ? data.color : undefined; - - const [sizeDraft, setSizeDraft] = useState(null); - const sizeValue = sizeDraft ?? String(fontSize); + const textColor = data.color; - 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 +118,8 @@ export function CanvasNodeRenderer({
{!isTextNode ? ( - <> - - - - + ) : null}
) : null} - {!isTextNode ? ( - <> - - - - ) : null} + {handles.map((handle) => ( + + ))} {selected ? ( - -
- - Font - - - - Size - - { - setSizeDraft(sizeValue); - event.currentTarget.select(); - }} - onChange={(event) => setSizeDraft(event.target.value)} - onBlur={() => { - if (sizeDraft !== null) commitFontSize(sizeDraft); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - if (sizeDraft !== null) commitFontSize(sizeDraft); - event.currentTarget.blur(); - } else if (event.key === "Escape") { - setSizeDraft(null); - event.currentTarget.blur(); - } - }} - className="nopan nodrag h-8 w-16 rounded-lg border border-surface-border bg-base px-2 text-xs text-copy-primary outline-none focus:border-brand" - title="Node font size (px)" - /> -
-
+ onChangeFont?.(id, font)} + onChangeFontSize={(next) => onChangeFontSize?.(id, next)} + onChangeColor={(pair) => onChangeColor?.(id, pair)} + /> ) : null} +
); } \ No newline at end of file diff --git a/components/editor/canvas.tsx b/components/editor/canvas.tsx index 86cc11f..866751d 100644 --- a/components/editor/canvas.tsx +++ b/components/editor/canvas.tsx @@ -3,10 +3,13 @@ import { Background, BackgroundVariant, + ConnectionMode, MiniMap, ReactFlow, ReactFlowProvider, useReactFlow, + type EdgeChange, + type EdgeProps, type NodeChange, type NodeProps, } from "@xyflow/react"; @@ -15,7 +18,8 @@ import { LiveblocksProvider, RoomProvider, } from "@liveblocks/react/suspense"; -import { useLiveblocksFlow } from "@liveblocks/react-flow"; +import { useLiveblocksFlow, Cursors } from "@liveblocks/react-flow"; +import { useCanRedo, useCanUndo, useRedo, useUndo } from "@liveblocks/react"; import { AlertTriangle, Loader2 } from "lucide-react"; import { Component, @@ -29,6 +33,7 @@ import { } from "react"; import { + DEFAULT_NODE_BG, DEFAULT_NODE_COLOR, SHAPE_DEFAULT_SIZES, SHAPE_DRAG_MIME, @@ -38,13 +43,23 @@ import { type CanvasNode, type CanvasNodeData, type CanvasNodeShape, + type NodeColorPair, type ShapeDragPayload, } from "@/types/canvas"; import { CANVAS_FONT_VARIABLES, DEFAULT_FONT_KEY } from "./canvas-fonts"; +import { CanvasControls } from "./canvas-controls"; +import { CanvasEdgeComponent } from "./canvas-edge"; import { CanvasNodeRenderer } from "./canvas-node"; import { ShapeOutline } from "./shape-outline"; import { ShapePanel } from "./shape-panel"; +import { CanvasCursor, PresenceOverlay } from "./presence-overlay"; +import { StarterTemplatesModal } from "./starter-templates-modal"; +import { buildTemplateImportChanges } from "./template-import"; +import type { CanvasTemplate } from "./starter-templates"; +import { useWorkspace } from "./workspace-context"; + +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import "@xyflow/react/dist/style.css"; import "@liveblocks/react-ui/styles.css"; @@ -52,19 +67,21 @@ import "@liveblocks/react-flow/styles.css"; interface CanvasProps { roomId: string; + /** Server-resolved permission; viewers get a read-only canvas. */ + canEdit: boolean; } -export function Canvas({ roomId }: CanvasProps) { +export function Canvas({ roomId, canEdit }: CanvasProps) { return ( }> - + @@ -73,7 +90,11 @@ export function Canvas({ roomId }: CanvasProps) { ); } -function CanvasFlow() { +interface CanvasFlowProps { + canEdit: boolean; +} + +function CanvasFlow({ canEdit }: CanvasFlowProps) { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, onDelete } = useLiveblocksFlow({ suspense: true, @@ -81,9 +102,15 @@ function CanvasFlow() { edges: { initial: [] }, }); - const { screenToFlowPosition } = useReactFlow(); + const flow = useReactFlow(); + const { screenToFlowPosition } = flow; const counterRef = useRef(0); + // Viewers may pan, zoom and select, but never mutate the shared document. + // The Liveblocks room grant enforces this server-side; this only keeps the + // UI honest so read-only users are not offered controls that would fail. + const { isStarterTemplatesOpen, closeStarterTemplates } = useWorkspace(); + const [ghost, setGhost] = useState<{ shape: CanvasNodeShape; x: number; @@ -92,8 +119,51 @@ function CanvasFlow() { const [editingId, setEditingId] = useState(null); + const [editingEdgeId, setEditingEdgeId] = useState(null); + + const canUndo = useCanUndo(); + const canRedo = useCanRedo(); + const undo = useUndo(); + const redo = useRedo(); + + useKeyboardShortcuts({ + flow, + onUndo: undo, + onRedo: redo, + enabled: canEdit, + }); + + // React Flow emits selection and measurement changes that are purely local; + // those stay allowed for viewers so the canvas remains navigable. + const handleNodesChange = useCallback( + (changes: NodeChange[]) => { + if (canEdit) { + onNodesChange(changes); + return; + } + const local = changes.filter( + (change) => change.type === "select" || change.type === "dimensions", + ); + if (local.length > 0) onNodesChange(local); + }, + [canEdit, onNodesChange], + ); + + const handleEdgesChange = useCallback( + (changes: EdgeChange[]) => { + if (canEdit) { + onEdgesChange(changes); + return; + } + const local = changes.filter((change) => change.type === "select"); + if (local.length > 0) onEdgesChange(local); + }, + [canEdit, onEdgesChange], + ); + const updateNodeData = useCallback( (id: string, patch: Partial) => { + if (!canEdit) return; const current = nodes.find((node) => node.id === id); if (!current) return; const change: NodeChange = { @@ -103,7 +173,7 @@ function CanvasFlow() { }; onNodesChange([change]); }, - [nodes, onNodesChange], + [canEdit, nodes, onNodesChange], ); const replaceLabel = useCallback( @@ -121,6 +191,12 @@ function CanvasFlow() { [updateNodeData], ); + const handleChangeColor = useCallback( + (id: string, pair: NodeColorPair) => + updateNodeData(id, { color: pair.text, bg: pair.bg }), + [updateNodeData], + ); + const handleStartEdit = useCallback( (id: string, label: string) => { setEditingId(id); @@ -143,6 +219,80 @@ function CanvasFlow() { [], ); + const handleDeleteNode = useCallback( + (id: string) => { + if (!canEdit) return; + setEditingId((current) => (current === id ? null : current)); + const nodeToDelete = nodes.find((node) => node.id === id); + if (!nodeToDelete) return; + const connectedEdges = edges.filter( + (edge) => edge.source === id || edge.target === id, + ); + onDelete({ nodes: [nodeToDelete], edges: connectedEdges }); + }, + [canEdit, nodes, edges, onDelete], + ); + + const handleImportTemplate = useCallback( + (template: CanvasTemplate) => { + if (!canEdit) return; + + const stamp = `${Date.now()}-${counterRef.current}`; + counterRef.current += 1; + + const { nodeChanges, edgeChanges } = buildTemplateImportChanges({ + nodes, + edges, + template, + stamp, + }); + + onNodesChange(nodeChanges); + onEdgesChange(edgeChanges); + closeStarterTemplates(); + requestAnimationFrame(() => { + flow.fitView({ padding: 0.2, duration: 300 }); + }); + }, + [ + canEdit, + nodes, + edges, + onNodesChange, + onEdgesChange, + closeStarterTemplates, + flow, + ], + ); + + const handleChangeEdgeLabel = useCallback( + (id: string, label: string) => { + if (!canEdit) return; + setEditingEdgeId((current) => (current === id ? null : current)); + const current = edges.find((edge) => edge.id === id); + if (!current) return; + const change: EdgeChange = { + type: "replace", + id, + item: { ...current, data: { ...current.data, label } }, + }; + onEdgesChange([change]); + }, + [canEdit, edges, onEdgesChange], + ); + + const edgeTypes = useMemo(() => { + const render = (props: EdgeProps) => ( + setEditingEdgeId(props.id)} + onCommitLabel={(label) => handleChangeEdgeLabel(props.id, label)} + /> + ); + return { canvasEdge: render, default: render }; + }, [editingEdgeId, handleChangeEdgeLabel]); + const nodeTypes = useMemo( () => ({ canvasNode: (props: NodeProps) => ( @@ -153,7 +303,9 @@ function CanvasFlow() { onChangeLabel={handleChangeLabel} onChangeFont={handleChangeFont} onChangeFontSize={handleChangeFontSize} + onChangeColor={handleChangeColor} onEndEdit={handleEndEdit} + onDeleteNode={handleDeleteNode} /> ), }), @@ -163,7 +315,9 @@ function CanvasFlow() { handleChangeLabel, handleChangeFont, handleChangeFontSize, + handleChangeColor, handleEndEdit, + handleDeleteNode, ], ); @@ -197,6 +351,7 @@ function CanvasFlow() { (event: DragEvent) => { event.preventDefault(); setGhost(null); + if (!canEdit) return; const raw = event.dataTransfer.getData(SHAPE_DRAG_MIME); if (!raw) return; @@ -224,6 +379,7 @@ function CanvasFlow() { data: { label: "", color: DEFAULT_NODE_COLOR, + bg: DEFAULT_NODE_BG, shape: payload.shape, font: DEFAULT_FONT_KEY, }, @@ -232,11 +388,12 @@ function CanvasFlow() { const change: NodeChange = { type: "add", item: newNode }; onNodesChange([change]); }, - [onNodesChange, screenToFlowPosition], + [canEdit, onNodesChange, screenToFlowPosition], ); const createTextNode = useCallback( (clientX: number, clientY: number) => { + if (!canEdit) return; const position = screenToFlowPosition({ x: clientX, y: clientY }); counterRef.current += 1; const id = `text-${Date.now()}-${counterRef.current}`; @@ -262,7 +419,7 @@ function CanvasFlow() { onNodesChange([change]); setEditingId(id); }, - [onNodesChange, screenToFlowPosition], + [canEdit, onNodesChange, screenToFlowPosition], ); const paneClickRef = useRef<{ x: number; y: number; time: number } | null>( @@ -298,11 +455,20 @@ function CanvasFlow() { nodes={nodes} edges={edges} nodeTypes={nodeTypes} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} - onConnect={onConnect} - onDelete={onDelete} + edgeTypes={edgeTypes} + onNodesChange={handleNodesChange} + onEdgesChange={handleEdgesChange} + onConnect={canEdit ? onConnect : undefined} + onDelete={canEdit ? onDelete : undefined} onPaneClick={handlePaneClick} + onEdgeDoubleClick={(event, edge) => { + if (canEdit) setEditingEdgeId(edge.id); + }} + nodesDraggable={canEdit} + nodesConnectable={canEdit} + edgesReconnectable={canEdit} + deleteKeyCode={canEdit ? undefined : null} + connectionMode={ConnectionMode.Loose} connectionRadius={40} zoomOnDoubleClick={false} fitView @@ -317,9 +483,17 @@ function CanvasFlow() { style={{ backgroundColor: "transparent" }} /> + - + + + {canEdit ? : } {ghost ? ( ) : null} + { + if (!open) closeStarterTemplates(); + }} + onImport={handleImportTemplate} + /> + + ); +} + +function ViewOnlyBadge() { + return ( +
+ View only \u2014 ask the owner for edit access
); } @@ -351,7 +540,7 @@ function DragGhost({ shape, x, y, size }: DragGhostProps) { height: size.height, }} > - +
{shape}
diff --git a/components/editor/color-swatches.tsx b/components/editor/color-swatches.tsx new file mode 100644 index 0000000..48ee8ef --- /dev/null +++ b/components/editor/color-swatches.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; + +import { NODE_COLORS, type NodeColorPair } from "@/types/canvas"; + +interface ColorSwatchesProps { + activeColor: string; + onSelect: (pair: NodeColorPair) => void; +} + +/** Tight glow tuned to the swatch text color — a ring plus a short bloom. */ +const glowShadow = (color: string) => + `0 0 0 1px ${color}, 0 0 6px 0 ${color}80`; + +const activeShadow = (color: string) => + `0 0 0 1.5px ${color}, 0 0 4px 0 ${color}66`; + +export function ColorSwatches({ activeColor, onSelect }: ColorSwatchesProps) { + const [hoveredKey, setHoveredKey] = useState(null); + + return ( +
+ {NODE_COLORS.map((pair) => { + const isActive = pair.text === activeColor; + const isHovered = hoveredKey === pair.key; + return ( +
+ ); +} diff --git a/components/editor/editor-navbar.tsx b/components/editor/editor-navbar.tsx index 81c4646..2f76d0d 100644 --- a/components/editor/editor-navbar.tsx +++ b/components/editor/editor-navbar.tsx @@ -2,6 +2,7 @@ import { UserButton } from "@clerk/nextjs"; import { + LayoutTemplate, PanelLeftClose, PanelLeftOpen, Share2, @@ -21,6 +22,7 @@ export function EditorNavbar() { toggleAiSidebar, isProjectSidebarOpen, toggleProjectSidebar, + openStarterTemplates, } = useWorkspace(); const [isShareOpen, setIsShareOpen] = useState(false); const ToggleIcon = isProjectSidebarOpen ? PanelLeftClose : PanelLeftOpen; @@ -52,6 +54,20 @@ export function EditorNavbar() {
+ {activeProject?.canEdit && ( + <> + + + )} {activeProject && ( <>
+ + ); +} diff --git a/components/editor/presence-overlay.tsx b/components/editor/presence-overlay.tsx new file mode 100644 index 0000000..68d52e9 --- /dev/null +++ b/components/editor/presence-overlay.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useUser, UserButton } from "@clerk/nextjs"; +import { useOthers, useUser as useLiveblocksUser } from "@liveblocks/react"; +import type { CursorsCursorProps } from "@liveblocks/react-flow"; +import { useMemo } from "react"; + +const MAX_VISIBLE_AVATARS = 5; + +interface Collaborator { + id: string; + name: string; + avatar?: string; + color: string; +} + +export function PresenceOverlay() { + const { user, isSignedIn } = useUser(); + const others = useOthers(); + + const collaborators = useMemo(() => { + const currentUserId = user?.id; + return others + .map((other) => ({ + id: other.id, + name: other.info?.name ?? "Anonymous", + avatar: other.info?.avatar, + color: other.info?.color ?? "#94a3b8", + })) + .filter((collaborator) => collaborator.id !== currentUserId); + }, [others, user?.id]); + + if (!isSignedIn || !user) return null; + + const visible = collaborators.slice(0, MAX_VISIBLE_AVATARS); + const overflowCount = Math.max(0, collaborators.length - MAX_VISIBLE_AVATARS); + + return ( +
+
+ {visible.map((collaborator) => ( + + ))} + {overflowCount > 0 ? ( + + ) : null} +
+ {collaborators.length > 0 ? ( +
+ ) : null} + +
+ ); +} + +function CollaboratorAvatar({ collaborator }: { collaborator: Collaborator }) { + const initials = collaborator.name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? "") + .join(""); + + return ( +
+ {collaborator.avatar ? ( + // eslint-disable-next-line @next/next/no-img-element + {collaborator.name} + ) : ( + {initials} + )} +
+ ); +} + +function OverflowChip({ count }: { count: number }) { + return ( +
+ +{count} +
+ ); +} + +export function CanvasCursor({ userId }: CursorsCursorProps) { + const { user, isLoading } = useLiveblocksUser(userId); + + if (isLoading) return null; + + const name = user?.name ?? "Anonymous"; + const color = user?.color ?? "#94a3b8"; + + return ( +
+ + + +
+ {name} +
+
+ ); +} \ No newline at end of file diff --git a/components/editor/shape-outline.tsx b/components/editor/shape-outline.tsx index a5d83f7..60f2642 100644 --- a/components/editor/shape-outline.tsx +++ b/components/editor/shape-outline.tsx @@ -5,22 +5,40 @@ import type { CanvasNodeShape } from "@/types/canvas"; interface ShapeOutlineProps { shape: CanvasNodeShape; color: string; + bg?: string; selected?: boolean; } -const FILL = "rgba(20, 20, 28, 0.85)"; +const DEFAULT_FILL = "rgba(20, 20, 28, 0.85)"; -export function ShapeOutline({ shape, color, selected = false }: ShapeOutlineProps) { - const strokeWidth = selected ? 3 : 2; - const strokeOpacity = selected ? 1 : 0.7; +/** CSS-border shapes cannot use `strokeOpacity`, so fold it into the color. */ +const withOpacity = (color: string, opacity: number): string => { + const hex = color.trim().replace("#", ""); + if (hex.length !== 6 || !/^[0-9a-f]{6}$/i.test(hex)) return color; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${opacity})`; +}; + +export function ShapeOutline({ + shape, + color, + bg, + selected = false, +}: ShapeOutlineProps) { + const strokeWidth = selected ? 1.5 : 1; + const strokeOpacity = selected ? 0.9 : 0.55; + const fill = bg ?? DEFAULT_FILL; + const borderColor = withOpacity(color, strokeOpacity); if (shape === "rectangle") { return (
); @@ -31,8 +49,8 @@ export function ShapeOutline({ shape, color, selected = false }: ShapeOutlinePro
); @@ -44,15 +62,15 @@ export function ShapeOutline({ shape, color, selected = false }: ShapeOutlinePro className="absolute inset-0" style={{ borderRadius: 9999, - border: `${strokeWidth}px solid ${color}`, - background: FILL, + border: `${strokeWidth}px solid ${borderColor}`, + background: fill, }} /> ); } const solid = { - fill: FILL, + fill, stroke: color, strokeWidth, strokeOpacity, @@ -62,7 +80,7 @@ export function ShapeOutline({ shape, color, selected = false }: ShapeOutlinePro fill: "none", stroke: color, strokeWidth, - strokeOpacity, + strokeOpacity: strokeOpacity * 0.7, } as const; if (shape === "diamond") { diff --git a/components/editor/share-dialog.tsx b/components/editor/share-dialog.tsx index 3f3b398..91cf95e 100644 --- a/components/editor/share-dialog.tsx +++ b/components/editor/share-dialog.tsx @@ -6,6 +6,7 @@ import { Link2, Loader2, Mail, + Pencil, ShieldCheck, Trash2, } from "lucide-react"; @@ -32,6 +33,7 @@ interface CollaboratorRow { email: string; status: "PENDING" | "ACTIVE"; canShare: boolean; + canEdit: boolean; displayName: string | null; avatarUrl: string | null; } @@ -64,6 +66,8 @@ export function ShareDialog({ const [loadError, setLoadError] = useState(null); const [emailInput, setEmailInput] = useState(""); + // New invites default to view-only; the owner opts into edit explicitly. + const [inviteCanEdit, setInviteCanEdit] = useState(false); const [isInviting, setIsInviting] = useState(false); const [inviteError, setInviteError] = useState(null); @@ -87,6 +91,7 @@ export function ShareDialog({ owner: OwnerProfile | null; collaborators: CollaboratorRow[]; canShare: boolean; + canEdit: boolean; }; setOwner(data.owner); setCollaborators(data.collaborators); @@ -109,6 +114,7 @@ export function ShareDialog({ (next: boolean) => { if (!next) { setEmailInput(""); + setInviteCanEdit(false); setInviteError(null); setCopied(false); } @@ -131,7 +137,7 @@ export function ShareDialog({ const res = await fetch(`/api/projects/${projectId}/collaborators`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email, canEdit: inviteCanEdit }), }); if (!res.ok) { const data = (await res.json().catch(() => ({}))) as ApiError; @@ -147,6 +153,7 @@ export function ShareDialog({ return [...prev, data.collaborator]; }); setEmailInput(""); + setInviteCanEdit(false); } catch (error) { setInviteError( error instanceof Error ? error.message : "Failed to invite", @@ -155,7 +162,7 @@ export function ShareDialog({ setIsInviting(false); } }, - [emailInput, projectId], + [emailInput, inviteCanEdit, projectId], ); @@ -184,8 +191,8 @@ export function ShareDialog({ [projectId], ); - const handleToggleCanShare = useCallback( - async (row: CollaboratorRow) => { + const patchPermission = useCallback( + async (row: CollaboratorRow, patch: { canShare?: boolean; canEdit?: boolean }) => { setBusyCollaboratorId(row.id); try { const res = await fetch( @@ -193,7 +200,7 @@ export function ShareDialog({ { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ canShare: !row.canShare }), + body: JSON.stringify(patch), }, ); if (!res.ok) { @@ -201,12 +208,16 @@ export function ShareDialog({ throw new Error(data.error ?? "Failed to update permission"); } const data = (await res.json()) as { - collaborator: { id: string; canShare: boolean }; + collaborator: { id: string; canShare: boolean; canEdit: boolean }; }; setCollaborators((prev) => prev.map((c) => c.id === data.collaborator.id - ? { ...c, canShare: data.collaborator.canShare } + ? { + ...c, + canShare: data.collaborator.canShare, + canEdit: data.collaborator.canEdit, + } : c, ), ); @@ -314,6 +325,25 @@ export function ShareDialog({ )}
+ {ownedByCurrentUser && ( +
+ + Invite as + +
+ setInviteCanEdit(false)} + /> + setInviteCanEdit(true)} + /> +
+
+ )} {inviteError && (

{inviteError}

)} @@ -363,12 +393,15 @@ export function ShareDialog({ badge={ collaborator.status === "PENDING" ? "PENDING" - : collaborator.canShare - ? "CAN SHARE" - : undefined + : collaborator.canEdit + ? "CAN EDIT" + : "VIEW ONLY" } badgeTone={ - collaborator.status === "PENDING" ? "muted" : "brand" + collaborator.status === "PENDING" || + !collaborator.canEdit + ? "muted" + : "brand" } > {ownedByCurrentUser && ( @@ -378,7 +411,41 @@ export function ShareDialog({ type="button" variant="ghost" size="icon-sm" - onClick={() => handleToggleCanShare(collaborator)} + onClick={() => + patchPermission(collaborator, { + canEdit: !collaborator.canEdit, + }) + } + disabled={isBusy} + aria-label={ + collaborator.canEdit + ? `Revoke edit access from ${collaborator.email}` + : `Grant edit access to ${collaborator.email}` + } + title={ + collaborator.canEdit + ? "Revoke edit access" + : "Grant edit access" + } + className={cn( + collaborator.canEdit + ? "text-brand" + : "text-copy-muted hover:text-copy-primary", + )} + > + + + )} + {collaborator.status === "ACTIVE" && ( +
); } + +interface RoleOptionProps { + label: string; + selected: boolean; + onSelect: () => void; +} + +function RoleOption({ label, selected, onSelect }: RoleOptionProps) { + return ( + + ); +} diff --git a/components/editor/small-screen-gate.tsx b/components/editor/small-screen-gate.tsx new file mode 100644 index 0000000..7bb0b80 --- /dev/null +++ b/components/editor/small-screen-gate.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Monitor } from "lucide-react"; +import { useEffect, useState } from "react"; + +/** + * The canvas workspace needs pointer precision and screen area that narrow + * viewports cannot provide, so below this width the editor is blocked outright + * rather than shipped in a state where controls overlap and drags misfire. + */ +export const WORKSPACE_MIN_WIDTH = 1024; + +/** + * Tracks whether the viewport is under the workspace minimum. + * Starts `null` so the first client render matches the server output and + * hydration does not mismatch. + */ +export function useIsBelowMinWidth(): boolean | null { + const [isBelow, setIsBelow] = useState(null); + + useEffect(() => { + const query = window.matchMedia( + `(max-width: ${WORKSPACE_MIN_WIDTH - 1}px)`, + ); + const sync = () => setIsBelow(query.matches); + sync(); + query.addEventListener("change", sync); + return () => query.removeEventListener("change", sync); + }, []); + + return isBelow; +} + +/** + * Full-screen blocker shown while the viewport is too narrow to use the + * workspace. It is not dismissible: it clears itself as soon as the window is + * widened past the minimum. + */ +export function SmallScreenGate() { + const isBelow = useIsBelowMinWidth(); + + if (isBelow !== true) return null; + + return ( +
+
+
+ +
+

+ Switch to a wider screen +

+

+ The workspace canvas needs at least {WORKSPACE_MIN_WIDTH}px of width + to work properly. Open this project on a desktop, or widen your + browser window, to keep editing. +

+
+
+ ); +} diff --git a/components/editor/starter-templates-modal.tsx b/components/editor/starter-templates-modal.tsx new file mode 100644 index 0000000..14f6587 --- /dev/null +++ b/components/editor/starter-templates-modal.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { ArrowDownToLine, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + CANVAS_TEMPLATES, + type CanvasTemplate, +} from "@/components/editor/starter-templates"; +import type { CanvasNode } from "@/types/canvas"; + +interface StarterTemplatesModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImport: (template: CanvasTemplate) => void; +} + +export function StarterTemplatesModal({ + open, + onOpenChange, + onImport, +}: StarterTemplatesModalProps) { + return ( + + +
+ + {/* NB: `text-base` is a COLOUR utility in this project + (--color-base), not a font size. Use the numeric scale. */} + Import Template + + Choose a starter template to add to your canvas. It is placed + beside your existing work, use ⌘Z to undo. + + + + + +
+ +
+ {CANVAS_TEMPLATES.map((template) => ( + + ))} +
+
+
+ ); +} + +function TemplateCard({ + template, + onImport, +}: { + template: CanvasTemplate; + onImport: (template: CanvasTemplate) => void; +}) { + return ( +
+
+ +
+
+ + {template.name} + + + {template.description} + +
+ +
+
+
+ ); +} + +const PREVIEW_WIDTH = 320; +const PREVIEW_HEIGHT = 200; +const PAD = 14; + +function TemplatePreview({ template }: { template: CanvasTemplate }) { + const { nodes, edges } = template; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const node of nodes) { + const w = node.width ?? 100; + const h = node.height ?? 60; + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x + w); + maxY = Math.max(maxY, node.position.y + h); + } + + const contentW = Math.max(1, maxX - minX); + const contentH = Math.max(1, maxY - minY); + const scale = Math.min( + (PREVIEW_WIDTH - PAD * 2) / contentW, + (PREVIEW_HEIGHT - PAD * 2) / contentH, + ); + const offsetX = (PREVIEW_WIDTH - contentW * scale) / 2 - minX * scale; + const offsetY = (PREVIEW_HEIGHT - contentH * scale) / 2 - minY * scale; + + const sx = (node: CanvasNode, v: number) => + offsetX + node.position.x * scale + v * scale; + const sy = (node: CanvasNode, v: number) => + offsetY + node.position.y * scale + v * scale; + + const centerOf = (node: CanvasNode) => ({ + x: sx(node, (node.width ?? 100) / 2), + y: sy(node, (node.height ?? 60) / 2), + }); + + const sideAnchor = (node: CanvasNode, towardX: number, towardY: number) => { + if (node.data.shape === "text") return centerOf(node); + const w = node.width ?? 100; + const h = node.height ?? 60; + const cx = sx(node, w / 2); + const cy = sy(node, h / 2); + const dx = towardX - cx; + const dy = towardY - cy; + if (Math.abs(dx) > Math.abs(dy)) { + return { x: dx >= 0 ? sx(node, w) : sx(node, 0), y: cy }; + } + return { x: cx, y: dy >= 0 ? sy(node, h) : sy(node, 0) }; + }; + + const nodeById = new Map(nodes.map((node) => [node.id, node])); + + return ( + + + + + + + + + {edges.map((edge) => { + const source = nodeById.get(edge.source); + const target = nodeById.get(edge.target); + if (!source || !target) return null; + const from = sideAnchor(source, centerOf(target).x, centerOf(target).y); + const to = sideAnchor(target, centerOf(source).x, centerOf(source).y); + const midX = (from.x + to.x) / 2; + const midY = (from.y + to.y) / 2; + return ( + + ); + })} + + {nodes.map((node) => { + const w = node.width ?? 100; + const h = node.height ?? 60; + return ( + + ); + })} + + ); +} + +// Rough advance width for the sans stack; good enough to decide when a preview +// label needs clipping. +const AVG_GLYPH_RATIO = 0.55; + +function truncateToWidth( + text: string, + maxWidth: number, + fontSize: number, +): string { + const charWidth = fontSize * AVG_GLYPH_RATIO; + const maxChars = Math.floor(maxWidth / charWidth); + if (maxChars >= text.length) return text; + if (maxChars < 2) return ""; + return `${text.slice(0, maxChars - 1).trimEnd()}\u2026`; +} + +function ShapeGlyph({ + node, + x, + y, + w, + h, +}: { + node: CanvasNode; + x: number; + y: number; + w: number; + h: number; +}) { + const data = node.data; + const fill = data.bg ?? "#14141c"; + const stroke = data.color; + const fontSize = Math.max(5, Math.min(11, Math.min(w * 0.22, h * 0.34))); + const cx = x + w / 2; + const cy = y + h / 2; + + // Labels are baked into the SVG, so they cannot rely on CSS wrapping. Clip + // them to the glyph width to keep previews from spilling past their shapes. + const isText = data.shape === "text"; + const maxLabelWidth = isText ? w : Math.max(0, w - 6); + const labelText = truncateToWidth(data.label, maxLabelWidth, fontSize); + + const label = labelText ? ( + + {labelText} + + ) : null; + + if (isText) { + return label; + } + + if (data.shape === "rectangle" || data.shape === "pill") { + const rx = data.shape === "pill" ? h / 2 : Math.min(w, h) / 6; + return ( + + + {label} + + ); + } + + if (data.shape === "circle") { + const r = Math.min(w, h) / 2; + return ( + + + {label} + + ); + } + + if (data.shape === "diamond") { + const points = `${cx},${y} ${x + w},${cy} ${cx},${y + h} ${x},${cy}`; + return ( + + + {label} + + ); + } + + if (data.shape === "hexagon") { + const inset = w * 0.12; + const points = `${x + inset},${y} ${x + w - inset},${y} ${x + w},${cy} ${x + w - inset},${y + h} ${x + inset},${y + h} ${x},${cy}`; + return ( + + + {label} + + ); + } + + return ( + + + + {label} + + ); +} \ No newline at end of file diff --git a/components/editor/starter-templates.ts b/components/editor/starter-templates.ts new file mode 100644 index 0000000..4aaf5e9 --- /dev/null +++ b/components/editor/starter-templates.ts @@ -0,0 +1,372 @@ +import { DEFAULT_FONT_KEY } from "@/components/editor/canvas-fonts"; +import { + NODE_COLORS, + SHAPE_DEFAULT_SIZES, + TEXT_DEFAULT_SIZE, + TEXT_NODE_COLOR, + type CanvasEdge, + type CanvasNode, + type CanvasNodeShape, +} from "@/types/canvas"; + +export interface CanvasTemplate { + id: string; + name: string; + description: string; + nodes: CanvasNode[]; + edges: CanvasEdge[]; +} + +const color = (key: string) => + NODE_COLORS.find((pair) => pair.key === key) ?? NODE_COLORS[0]; + +interface NodeOptions { + color?: string; + fontKey?: string; + fontSize?: number; +} + +function node( + id: string, + x: number, + y: number, + shape: CanvasNodeShape, + label: string, + options: NodeOptions = {}, +): CanvasNode { + const pair = color(options.color ?? "neutral"); + const size = SHAPE_DEFAULT_SIZES[shape]; + return { + id, + type: "canvasNode", + position: { x, y }, + width: size.width, + height: size.height, + data: { + label, + color: pair.text, + bg: pair.bg, + shape, + font: options.fontKey ?? DEFAULT_FONT_KEY, + ...(options.fontSize != null ? { fontSize: options.fontSize } : {}), + }, + }; +} + +function text( + id: string, + x: number, + y: number, + label: string, + fontSize = 16, +): CanvasNode { + return { + id, + type: "canvasNode", + position: { x, y }, + width: TEXT_DEFAULT_SIZE.width, + height: TEXT_DEFAULT_SIZE.height, + data: { + label, + color: TEXT_NODE_COLOR, + shape: "text", + font: DEFAULT_FONT_KEY, + ...(fontSize ? { fontSize } : {}), + }, + }; +} + +function edge( + id: string, + source: string, + target: string, + label?: string, +): CanvasEdge { + return { + id, + type: "canvasEdge", + source, + target, + ...(label ? { data: { label } } : {}), + }; +} + +export const CANVAS_TEMPLATES: CanvasTemplate[] = [ + { + id: "microservices", + name: "Microservices", + description: + "API gateway with focused services, an event bus, and shared data stores.", + nodes: [ + node("gateway", 20, 220, "hexagon", "API Gateway", { color: "blue" }), + node("auth", 320, 40, "rectangle", "Auth Service", { color: "purple" }), + node("users", 320, 200, "rectangle", "Users Service", { + color: "teal", + }), + node("orders", 320, 360, "rectangle", "Orders Service", { + color: "orange", + }), + node("payments", 320, 520, "rectangle", "Payments Service", { + color: "pink", + }), + node("bus", 680, 300, "circle", "Event Bus", { color: "green" }), + node("cache", 1020, 120, "cylinder", "Redis Cache", { color: "red" }), + node("db", 1020, 420, "cylinder", "Postgres", { color: "blue" }), + ], + edges: [ + edge("e-gw-auth", "gateway", "auth", "login"), + edge("e-gw-users", "gateway", "users"), + edge("e-gw-orders", "gateway", "orders"), + edge("e-gw-payments", "gateway", "payments"), + edge("e-auth-bus", "auth", "bus", "user.registered"), + edge("e-users-bus", "users", "bus"), + edge("e-orders-bus", "orders", "bus", "order.created"), + edge("e-pay-bus", "payments", "bus"), + edge("e-bus-cache", "bus", "cache"), + edge("e-orders-cache", "orders", "cache"), + edge("e-bus-db", "bus", "db", "persist"), + edge("e-pay-db", "payments", "db"), + ], + }, + { + id: "ci-cd", + name: "CI/CD Pipeline", + description: + "Automated flow from source control through testing to production.", + nodes: [ + text("title", 0, 0, "CI/CD Pipeline", 18), + node("source", 40, 140, "pill", "Source Control", { color: "blue" }), + node("build", 300, 140, "pill", "Build", { color: "purple" }), + node("test", 560, 140, "pill", "Tests", { color: "teal" }), + node("artifact", 820, 140, "pill", "Artifact", { color: "orange" }), + node("deploy", 1080, 140, "pill", "Deploy", { color: "green" }), + node("monitor", 1340, 140, "pill", "Monitor", { color: "red" }), + ], + edges: [ + edge("e-source-build", "source", "build", "on push"), + edge("e-build-test", "build", "test", "unit tests"), + edge("e-test-artifact", "test", "artifact", "upload"), + edge("e-artifact-deploy", "artifact", "deploy", "production"), + edge("e-deploy-monitor", "deploy", "monitor", "metrics"), + ], + }, + { + id: "event-driven", + name: "Event-Driven", + description: + "Producers publish to a broker that fans out to queues and consumers.", + nodes: [ + node("p1", 20, 0, "rectangle", "Order Producer", { color: "blue" }), + node("p2", 20, 180, "rectangle", "User Producer", { color: "teal" }), + node("p3", 20, 360, "rectangle", "Payment Producer", { + color: "pink", + }), + node("broker", 360, 180, "circle", "Event Broker", { + color: "purple", + }), + node("q1", 680, 40, "pill", "Orders Queue", { color: "orange" }), + node("q2", 680, 320, "pill", "Emails Queue", { color: "purple" }), + node("c1", 1000, 40, "rectangle", "Order Service", { color: "green" }), + node("c2", 1000, 320, "rectangle", "Email Service", { color: "blue" }), + ], + edges: [ + edge("e-p1-broker", "p1", "broker", "order.placed"), + edge("e-p2-broker", "p2", "broker", "user.signup"), + edge("e-p3-broker", "p3", "broker", "payment.succeeded"), + edge("e-broker-q1", "broker", "q1"), + edge("e-broker-q2", "broker", "q2"), + edge("e-q1-c1", "q1", "c1"), + edge("e-q2-c2", "q2", "c2"), + ], + }, + { + id: "auth-flow", + name: "Auth Flow", + description: "Login, credential validation, session issuance, and access.", + nodes: [ + node("login", 20, 100, "rectangle", "Login Form", { color: "blue" }), + node("validate", 300, 140, "diamond", "Validate", { + color: "orange", + }), + node("issue", 560, 40, "rectangle", "Issue JWT", { color: "purple" }), + node("store", 560, 260, "cylinder", "Session Store", { color: "teal" }), + node("gate", 860, 160, "diamond", "Authorized?", { color: "green" }), + node("route", 1140, 160, "rectangle", "Protected Route", { + color: "green", + }), + node("reject", 560, 420, "rectangle", "401 Unauthorized", { + color: "red", + }), + ], + edges: [ + edge("e-login-validate", "login", "validate"), + edge("e-validate-issue", "validate", "issue", "valid"), + edge("e-validate-reject", "validate", "reject", "invalid"), + edge("e-issue-store", "issue", "store", "session"), + edge("e-issue-gate", "issue", "gate", "token"), + edge("e-store-gate", "store", "gate"), + edge("e-gate-route", "gate", "route", "yes"), + edge("e-gate-reject", "gate", "reject", "no"), + ], + }, + { + id: "api-gateway", + name: "API Gateway", + description: "A gateway fronting services with auth, routing, and limits.", + nodes: [ + node("client", 20, 180, "circle", "Clients", { color: "blue" }), + node("gateway", 280, 180, "hexagon", "API Gateway", { color: "purple" }), + node("auth", 620, 0, "rectangle", "Auth", { color: "teal" }), + node("router", 620, 160, "rectangle", "Router", { color: "orange" }), + node("ratelimit", 620, 320, "rectangle", "Rate Limiter", { + color: "red", + }), + node("svc1", 960, 0, "rectangle", "Service A", { color: "green" }), + node("svc2", 960, 160, "rectangle", "Service B", { color: "blue" }), + node("svc3", 960, 320, "rectangle", "Service C", { color: "pink" }), + node("cache", 280, 420, "cylinder", "Cache", { color: "purple" }), + ], + edges: [ + edge("e-client-gw", "client", "gateway"), + edge("e-gw-auth", "gateway", "auth"), + edge("e-auth-gw", "auth", "gateway", "whoami"), + edge("e-gw-router", "gateway", "router"), + edge("e-gw-limited", "gateway", "ratelimit"), + edge("e-limited-gw", "ratelimit", "gateway", "throttle"), + edge("e-router-svc1", "router", "svc1"), + edge("e-router-svc2", "router", "svc2"), + edge("e-router-svc3", "router", "svc3"), + edge("e-cache-router", "cache", "router", "cached"), + ], + }, + { + id: "rate-limiting", + name: "Rate Limiting", + description: "Sliding-window rate limiting with a shared counter store.", + nodes: [ + node("request", 20, 100, "pill", "Request", { color: "blue" }), + node("identify", 300, 100, "rectangle", "Identify Client", { + color: "purple", + }), + node("window", 580, 100, "diamond", "Within Limit?", { + color: "orange", + }), + node("counters", 580, 320, "cylinder", "Counter Store", { color: "teal" }), + node("allow", 900, 20, "pill", "Allow", { color: "green" }), + node("reject", 900, 200, "pill", "Reject", { color: "red" }), + ], + edges: [ + edge("e-req-identify", "request", "identify"), + edge("e-identify-window", "identify", "window"), + edge("e-window-counters", "window", "counters", "fetched"), + edge("e-counters-window", "counters", "window", "count"), + edge("e-window-allow", "window", "allow", "yes"), + edge("e-window-reject", "window", "reject", "no"), + ], + }, + { + id: "sliding-window", + name: "Sliding Window", + description: "Visualize time-bucketed request counts sliding over time.", + nodes: [ + text("title", 0, 0, "Sliding Window Log", 18), + node("t1", 40, 120, "pill", "t-3s · 12 req", { color: "purple" }), + node("t2", 300, 120, "pill", "t-2s · 7 req", { color: "blue" }), + node("t3", 560, 120, "pill", "t-1s · 18 req", { color: "teal" }), + node("t4", 820, 120, "pill", "now · 9 req", { color: "orange" }), + node("evict", 560, 320, "rectangle", "Evict expired buckets", { + color: "red", + }), + node("log", 820, 320, "cylinder", "Request Log", { color: "green" }), + ], + edges: [ + edge("e-t1-t2", "t1", "t2"), + edge("e-t2-t3", "t2", "t3"), + edge("e-t3-t4", "t3", "t4"), + edge("e-t4-log", "t4", "log", "append"), + edge("e-log-evict", "log", "evict"), + edge("e-evict-t1", "evict", "t1", "drop"), + ], + }, + { + id: "nextjs", + name: "Next.js", + description: "App Router request path through edge, React, and routes.", + nodes: [ + node("browser", 20, 200, "circle", "Browser", { color: "blue" }), + node("edge", 300, 40, "rectangle", "Edge Middleware", { + color: "purple", + }), + node("server", 300, 200, "hexagon", "Server Components", { + color: "green", + }), + node("client", 300, 380, "rectangle", "Client Components", { + color: "teal", + }), + node("api", 680, 200, "rectangle", "Route Handlers", { + color: "orange", + }), + node("db", 980, 200, "cylinder", "Database", { color: "red" }), + ], + edges: [ + edge("e-browser-edge", "browser", "edge"), + edge("e-edge-server", "edge", "server", "RSC"), + edge("e-edge-client", "edge", "client"), + edge("e-server-client", "server", "client"), + edge("e-server-api", "server", "api", "fetch"), + edge("e-api-db", "api", "db", "SQL"), + edge("e-db-api", "db", "api"), + ], + }, + { + id: "nestjs", + name: "NestJS", + description: "Request lifecycle through guards, controllers, and services.", + nodes: [ + node("req", 20, 200, "pill", "HTTP Request", { color: "blue" }), + node("guards", 280, 40, "rectangle", "Guards", { color: "purple" }), + node("controller", 280, 200, "rectangle", "Controller", { + color: "green", + }), + node("service", 280, 360, "rectangle", "Service", { color: "orange" }), + node("interceptors", 620, 40, "rectangle", "Interceptors", { + color: "teal", + }), + node("repo", 620, 360, "rectangle", "Repository", { color: "pink" }), + node("db", 940, 360, "cylinder", "Postgres", { color: "blue" }), + ], + edges: [ + edge("e-req-guards", "req", "guards"), + edge("e-guards-controller", "guards", "controller", "auth"), + edge("e-controller-service", "controller", "service", "useCase"), + edge("e-controller-interceptors", "controller", "interceptors"), + edge("e-interceptors-req", "interceptors", "req", "response"), + edge("e-service-repo", "service", "repo"), + edge("e-repo-db", "repo", "db", "SQL"), + ], + }, + { + id: "payment-gateway", + name: "Payment Gateway", + description: "Checkout, provider interaction, verification, and capture.", + nodes: [ + node("checkout", 20, 100, "rectangle", "Checkout", { color: "blue" }), + node("provider", 300, 100, "rectangle", "Payment Provider", { + color: "purple", + }), + node("verify", 580, 20, "diamond", "3DS Verify", { color: "orange" }), + node("capture", 580, 200, "rectangle", "Capture", { color: "green" }), + node("webhook", 860, 200, "rectangle", "Webhook", { color: "teal" }), + node("order", 1140, 100, "pill", "Order Confirmed", { color: "green" }), + node("reject", 580, 380, "pill", "Payment Failed", { color: "red" }), + ], + edges: [ + edge("e-checkout-provider", "checkout", "provider", "charge"), + edge("e-provider-verify", "provider", "verify"), + edge("e-verify-capture", "verify", "capture", "verified"), + edge("e-verify-reject", "verify", "reject", "declined"), + edge("e-capture-webhook", "capture", "webhook", "event"), + edge("e-webhook-order", "webhook", "order"), + ], + }, +]; \ No newline at end of file diff --git a/components/editor/template-import.ts b/components/editor/template-import.ts new file mode 100644 index 0000000..80034f9 --- /dev/null +++ b/components/editor/template-import.ts @@ -0,0 +1,120 @@ +import type { EdgeChange, NodeChange } from "@xyflow/react"; + +import type { CanvasEdge, CanvasNode } from "@/types/canvas"; + +import type { CanvasTemplate } from "./starter-templates"; + +/** Horizontal breathing room between existing content and an import. */ +export const IMPORT_GAP = 120; + +/** + * Offset that places a template immediately to the right of everything already + * on the canvas, vertically aligned with the existing content's top edge. + * Returns a zero offset for an empty canvas so the first import lands as authored. + */ +export function importOffsetFor( + existing: ReadonlyArray, + template: CanvasTemplate, +): { x: number; y: number } { + if (existing.length === 0) return { x: 0, y: 0 }; + + let existingRight = -Infinity; + let existingTop = Infinity; + for (const node of existing) { + existingRight = Math.max( + existingRight, + node.position.x + (node.width ?? node.measured?.width ?? 0), + ); + existingTop = Math.min(existingTop, node.position.y); + } + if (!Number.isFinite(existingRight) || !Number.isFinite(existingTop)) { + return { x: 0, y: 0 }; + } + + let templateLeft = Infinity; + let templateTop = Infinity; + for (const node of template.nodes) { + templateLeft = Math.min(templateLeft, node.position.x); + templateTop = Math.min(templateTop, node.position.y); + } + if (!Number.isFinite(templateLeft) || !Number.isFinite(templateTop)) { + return { x: 0, y: 0 }; + } + + return { + x: existingRight + IMPORT_GAP - templateLeft, + y: existingTop - templateTop, + }; +} + +export interface TemplateImportChanges { + nodeChanges: NodeChange[]; + edgeChanges: EdgeChange[]; +} + +/** + * Build the change-set that adds `template` to an existing canvas. + * + * Two invariants matter here: + * - The import is additive; nothing existing is deleted or moved. + * - The resulting selection is empty. React Flow drags every selected node as + * one unit, so leaving a stale selection (or pre-selecting the import) makes + * the imported template and untouched pre-existing nodes move together. + */ +export function buildTemplateImportChanges({ + nodes, + edges, + template, + stamp, +}: { + nodes: ReadonlyArray; + edges: ReadonlyArray; + template: CanvasTemplate; + stamp: string; +}): TemplateImportChanges { + const idFor = new Map(); + template.nodes.forEach((node) => + idFor.set(node.id, `${template.id}-${stamp}-${node.id}`), + ); + + const offset = importOffsetFor(nodes, template); + + const deselectNodes: NodeChange[] = nodes + .filter((node) => node.selected) + .map((node) => ({ type: "select", id: node.id, selected: false })); + const deselectEdges: EdgeChange[] = edges + .filter((edge) => edge.selected) + .map((edge) => ({ type: "select", id: edge.id, selected: false })); + + const additions: NodeChange[] = template.nodes.map((node) => ({ + type: "add", + item: { + ...node, + id: idFor.get(node.id) ?? node.id, + position: { + x: node.position.x + offset.x, + y: node.position.y + offset.y, + }, + // Imported nodes land unselected so each one drags independently. + selected: false, + }, + })); + + const edgeAdditions: EdgeChange[] = template.edges.map( + (edge) => ({ + type: "add", + item: { + ...edge, + id: `${template.id}-${stamp}-${edge.id}`, + source: idFor.get(edge.source) ?? edge.source, + target: idFor.get(edge.target) ?? edge.target, + selected: false, + }, + }), + ); + + return { + nodeChanges: [...deselectNodes, ...additions], + edgeChanges: [...deselectEdges, ...edgeAdditions], + }; +} diff --git a/components/editor/workspace-context.tsx b/components/editor/workspace-context.tsx index 08cdd52..19c20ab 100644 --- a/components/editor/workspace-context.tsx +++ b/components/editor/workspace-context.tsx @@ -13,6 +13,8 @@ export interface ActiveProject { id: string; name: string; ownedByCurrentUser: boolean; + /** False for collaborators the owner has not granted edit access. */ + canEdit: boolean; } interface WorkspaceContextValue { @@ -24,6 +26,9 @@ interface WorkspaceContextValue { isProjectSidebarOpen: boolean; toggleProjectSidebar: () => void; closeProjectSidebar: () => void; + isStarterTemplatesOpen: boolean; + openStarterTemplates: () => void; + closeStarterTemplates: () => void; } const WorkspaceContext = createContext(null); @@ -38,6 +43,7 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { ); const [isAiSidebarOpen, setIsAiSidebarOpen] = useState(false); const [isProjectSidebarOpen, setIsProjectSidebarOpen] = useState(false); + const [isStarterTemplatesOpen, setIsStarterTemplatesOpen] = useState(false); const toggleAiSidebar = useCallback(() => { setIsAiSidebarOpen((prev) => !prev); @@ -55,6 +61,14 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { setIsProjectSidebarOpen(false); }, []); + const openStarterTemplates = useCallback(() => { + setIsStarterTemplatesOpen(true); + }, []); + + const closeStarterTemplates = useCallback(() => { + setIsStarterTemplatesOpen(false); + }, []); + const value = useMemo( () => ({ activeProject, @@ -65,6 +79,9 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { isProjectSidebarOpen, toggleProjectSidebar, closeProjectSidebar, + isStarterTemplatesOpen, + openStarterTemplates, + closeStarterTemplates, }), [ activeProject, @@ -74,6 +91,9 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { isProjectSidebarOpen, toggleProjectSidebar, closeProjectSidebar, + isStarterTemplatesOpen, + openStarterTemplates, + closeStarterTemplates, ], ); diff --git a/components/editor/workspace-shell.tsx b/components/editor/workspace-shell.tsx index b254e94..5f2d6d8 100644 --- a/components/editor/workspace-shell.tsx +++ b/components/editor/workspace-shell.tsx @@ -4,6 +4,10 @@ import { MessageSquare, Sparkles, X } from "lucide-react"; import { useEffect } from "react"; import { Canvas } from "@/components/editor/canvas"; +import { + SmallScreenGate, + useIsBelowMinWidth, +} from "@/components/editor/small-screen-gate"; import { useWorkspace, type ActiveProject, @@ -22,6 +26,8 @@ export function WorkspaceShell({ project }: WorkspaceShellProps) { closeAiSidebar, isProjectSidebarOpen, } = useWorkspace(); + const isBelowMinWidth = useIsBelowMinWidth(); + const isBlocked = isBelowMinWidth === true; useEffect(() => { setActiveProject(project); @@ -31,6 +37,8 @@ export function WorkspaceShell({ project }: WorkspaceShellProps) { return (
- +
+
); } diff --git a/context/architecture-context.md b/context/architecture-context.md index 1b462bd..b5147c5 100644 --- a/context/architecture-context.md +++ b/context/architecture-context.md @@ -36,12 +36,30 @@ - Only authenticated users can access protected routes. - Only the owner or a collaborator can mutate project resources. - Liveblocks room tokens are issued only after verifying project membership. +- Membership and edit rights are separate: `ProjectCollaborator.canEdit` (default + `false`) decides whether a collaborator may mutate the canvas. Owners always + can. The owner chooses view or edit when sending an invite, and can change it + afterwards. +- Edit rights are enforced in the Liveblocks room grant, not just the UI: + editors get `room:write`, viewers get `room:read` + `room:presence:write`, so + a viewer keeps live cursors but the server rejects storage mutations. +- Changing `canEdit` re-syncs the room grant immediately, so a revoked + collaborator loses write access on their current connection rather than at + token expiry. +- `canShare` (invite rights) and `canEdit` (mutation rights) are independent + flags; a collaborator with share rights can only ever invite view-only users. ## Starter System Designs - Prebuilt templates are static canvas snapshots stored in the codebase. - Templates are loaded into the active Liveblocks room when a user imports one. +- Import is additive: an imported template is offset to sit clear of existing + content and never replaces or deletes what is already on the canvas. +- An import leaves the canvas selection empty. React Flow moves every selected + node together, so a shared selection between imported and pre-existing nodes + would make unrelated elements drag as one unit. - Import can occur on canvas creation or from within the editor at any time. +- Importing requires edit rights; the Templates entry point is hidden from viewers. - Template data follows the same node/edge schema as user-created canvas content. - Templates do not require a separate database record; they are resolved by template ID at import time. @@ -66,3 +84,8 @@ 3. Auth and ownership are enforced at every mutation boundary. 4. Client components are used only where browser interactivity or real-time state requires them. 5. The canvas schema must remain consistent between user-created content and imported templates. +6. Canvas mutations require edit rights, enforced server-side by the Liveblocks + room grant; client-side gating is a UX affordance, never the security boundary. +7. Importing a template never destroys existing canvas content. +8. The editor workspace requires a viewport of at least 1024px; below that it is + blocked outright rather than degraded. diff --git a/context/progress-tracker.md b/context/progress-tracker.md index d32e9fd..9803cd0 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -4,15 +4,72 @@ Update this file whenever the current phase, active feature, or implementation s ## Current Phase -- Phase 5: Realtime — Liveblocks wired into the workspace via a collaborative React Flow canvas, with drag-to-create shape support, proper shape rendering, and node resizing + inline label editing. +- Phase 5: Realtime — Liveblocks wired into the workspace via a collaborative React Flow canvas, with drag-to-create shape support, proper shape rendering, node resizing + inline label editing, free-standing text annotations, a floating node style toolbar (font, size, color themes), edge labels, zoom/undo-redo controls + keyboard shortcuts, and an importable starter-template library. ## Current Goal -- Feature 14: Node — resize handles on selected nodes + inline label editing (centered textarea, blur/Escape to close). +- Starter Template Library — importable prebuilt diagrams opened from the editor navbar, with lightweight SVG previews and canvas-replacing import. ## Completed -- Feature 14: Node (resize + inline label editing) +- Room presence: collaborator avatars + live cursors (canvas view only) + - `liveblocks.config.ts`: presence type now `{ cursor: { x, y } | null; thinking: boolean }` — `isThinking` renamed to `thinking` to match the presence contract. `cursor` is the key the React Flow cursors layer writes to and reads from. + - `components/editor/presence-overlay.tsx` (new): the presence group pinned to the canvas top-right (`absolute top-3 right-3`, above the flow). Resolves the current user via Clerk `useUser()` and filters the Liveblocks `useOthers()` list to exclude `other.id === user.id` (the auth route already identifies Liveblocks users with the Clerk user id, so the IDs line up). Renders up to 5 overlapping collaborator avatars (photo via `` when `other.info.avatar` exists, else initials on the participant's `color`), a `+N` overflow chip past 5, a 1px divider only when at least one collaborator exists, then the Clerk `UserButton` for the current user — same `h-7 w-7` size as the avatars. Avatars are display-only (`title` tooltip, no handlers); avatars/chip carry a dark `ring-2 ring-[#0a0a12]` so the stack stays readable on the dark canvas. Rendered inside `CanvasFlow`'s wrapper, so it exists only in the room view — the shared navbar is untouched. + - `components/editor/canvas.tsx`: added the bundled `` component (from `@liveblocks/react-flow`) as a child of ``. It broadcasts the current user's cursor via `useUpdateMyPresence` on `pointermove` (converting screen→flow coords with `screenToFlowPosition`, skipped while panning), clears to `null` on pointer leave/blur, and renders only *other* participants' cursors (from `useOthersConnectionIds`), positioned through the live viewport transform with a spring. Each cursor reuses the `@liveblocks/react-ui` `Cursor` (colored pointer + name badge), colored from `user.info.color` set by the auth route's `getCursorColorForUser`. `RoomProvider initialPresence` updated to `{ cursor: null, thinking: false }`. + - `tsc --noEmit`, `eslint`, and `npm run build` all pass. + +- Starter Template Library (spec provided by the user) + - `components/editor/starter-templates.ts` (new): `CanvasTemplate { id, name, description, nodes, edges }` + `CANVAS_TEMPLATES` with 10 templates — Microservices, CI/CD Pipeline, Event-Driven, Auth Flow, API Gateway, Rate Limiting, Sliding Window, Next.js, NestJS, Payment Gateway. Built with tiny helpers `node()`/`text()`/`edge()` that wrap the shared `types/canvas.ts` types, `NODE_COLORS` pairs, `SHAPE_DEFAULT_SIZES`, `TEXT_DEFAULT_SIZE`, `TEXT_NODE_COLOR`, and `DEFAULT_FONT_KEY`, so imported nodes/edges match the canvas data model exactly. `node()` derives `color`/`bg` from `NODE_COLORS` by key; text-title nodes and labeled edges are supported. + - `components/editor/starter-templates-modal.tsx` (new): an `Import Template` dialog (`rounded-2xl`) with a bold heading, a short "…any existing nodes will be replaced, use ⌘Z to undo" subtitle, and a custom close button that clears the modal edge. The grid is horizontally oriented — `grid-cols-1 sm:grid-cols-[repeat(auto-fill,minmax(240px,1fr))]` with `gap-7` — so cards hold a 240px floor and the column count degrades on its own (5 → 4 → 3 → 2 → 1) instead of squeezing card width. Verified in a headless Chromium render at 1920/1440/1280/1024/768: 5/5/4/3/2 columns, card width 245–306px, every title on one line, no clipped descriptions, and `document.scrollWidth === window.innerWidth` at all five widths (no horizontal scroll). Each card is a rounded surface with inset highlight + drop shadow: a large pure-SVG preview (side-anchored, source-colored curved edges, filled shapes, dotted canvas background), the description, and a full-width primary Import CTA at the bottom. The preview is an `aspect-[8/5] w-full` SVG on a 320x200 viewBox, so it scales with the card rather than sitting as a small letterboxed icon. Card body is `p-5`/`gap-2`; the title is `line-clamp-2 break-words` and the description `line-clamp-3 break-words`, so text wraps on word boundaries and ellipsizes rather than breaking mid-word. No React Flow, no Liveblocks — previews are static SVG only. + - `components/editor/workspace-context.tsx`: added `isStarterTemplatesOpen` + `openStarterTemplates`/`closeStarterTemplates` so the navbar button and the canvas modal share the open state across the workspace tree. + - `components/editor/editor-navbar.tsx`: added a "Templates" ghost button (`LayoutTemplate` icon) beside Share, visible only when a project is active; opens the modal via `openStarterTemplates`. + - `components/editor/canvas.tsx`: `CanvasFlow` reads the modal state from `useWorkspace` and renders ``. `handleImportTemplate` rewrites node/edge ids with a per-import `{templateId}-{stamp}-{original}` suffix, maps edge source/target through the id map, clears the canvas via the collaborative `onDelete({ nodes, edges })` (the working delete path — `remove` changes are a no-op in this bundle), adds the template through `onNodesChange`/`onEdgesChange` `add` changes, closes the modal, then `flow.fitView({ padding: 0.2, duration: 300 })` on the next animation frame so the freshly-added nodes are measured. All mutations stay inside the Liveblocks flow state. + - Modal width: `DialogContent`'s base class list carries `sm:max-w-sm`, and tailwind-merge does not dedupe a responsive-prefixed utility against an unprefixed one — so the earlier `max-w-7xl` was silently overridden and the dialog was pinned at 24rem on every desktop viewport. Fixed by passing both `max-w-[1400px]` and `sm:max-w-[1400px]` alongside `w-[calc(100vw-4rem)]`. Any future width override on a shadcn dialog must set the `sm:` variant too. + - Preview node labels are baked into the SVG and cannot rely on CSS wrapping, so `truncateToWidth()` clips each label to its glyph width with an ellipsis. Without it, long labels spilled past their shapes and were clipped at the SVG edge. + - Template edges use the `canvasEdge` type, which is registered in `edgeTypes` (alongside `default`), so imported labeled edges render. `tsc --noEmit` and `eslint` clean; `npm run build` passes. + +- Fix: connections rejected on half of each node, edges bending inward + - Root cause: nodes declared `type="target"` handles on top/left and `type="source"` handles on bottom/right. In React Flow's default strict connection mode a source only connects to a target, so the top and left points refused to start a connection and the bottom and right points refused to accept one. Edges then had to reach the one legal handle, which is what produced the long inward detours. + - `components/editor/canvas.tsx`: added `connectionMode={ConnectionMode.Loose}` — handle `type` no longer restricts connections, so all four sides both start and accept. Handle ids (`target-top`, `source-bottom`, …) are deliberately unchanged so edges saved before this still resolve. + - `components/editor/canvas-node.tsx`: the handle element is now a 16px invisible grab target with the visible 7px dot painted by a radial gradient (`HANDLE_DOT` / `HANDLE_HIT`). A 7px hit area — invisible until selection — was too small to reliably grab, which contributed to connections that would not start. + - Text annotations still have no handles (Feature 15 scope: they are plain text boxes, no outline and no connection points). + +- Fix: new workspace missing from the sidebar until reload + - `hooks/use-project-actions.ts`: the create branch called `router.refresh()` **before** `router.push()`. The router processes queued actions in order, so the refresh revalidated the route being left, and the push then rendered the new route reusing the already-cached `/editor` layout — the layout holds the sidebar project list, and shared layouts are not re-fetched on navigation to a nested route. Swapped to `push()` then `refresh()` so the refresh applies to the new route and re-fetches the layout. + - `app/api/projects/route.ts` and `app/api/projects/[projectId]/route.ts`: `revalidatePath("/editor")` → `revalidatePath("/editor", "layout")`. The page-scoped form only invalidated the `/editor` page, never the layout that actually renders the list (create/rename/delete all shared this gap). + +- Node Color Themes + Style Toolbar (spec: `context/feature-specs/15-text-annotation.md`) + - `types/canvas.ts`: added `NodeColorPair { key, label, bg, text }` and `NODE_COLORS` — the 8 pairs documented in `ui-context.md` (neutral, blue, purple, orange, red, pink, green, teal). No new `globals.css` tokens: these are canvas data values written into node data, not theme surfaces. `DEFAULT_NODE_COLOR_PAIR`/`DEFAULT_NODE_COLOR` (`#FFFFFF`)/`DEFAULT_NODE_BG` (`#000000`) replace the old `#a78bfa` default. `CanvasNodeData` gains `bg?: string` — `color` stays the accent (outline + label text), `bg` is the fill, so one swatch drives both without a second lookup at render time. + - `components/editor/color-swatches.tsx` (new): one round swatch per pair (fill = `pair.bg`, border = `pair.text`). Active pair is matched on `pair.text === data.color` and gets a tight 1.5px ring plus a slight scale; hover applies a controlled glow (`0 0 0 1px , 0 0 6px 80`) — deliberately short-radius, not a soft bloom. + - `components/editor/font-select.tsx` (new): replaces the native `` listing `CANVAS_FONTS`; choosing a font writes `data.font` via `onChangeFont`. The value applies to both shape labels and annotation text (`fontFamily = fontCssVar(data.font)`), and labels/textarea use `whiteSpace: "pre-wrap"` so Enter renders a real line break. + - Label font scales with node width via `scaleFont` (existing resize behavior). The toolbar also carries a numeric Size input (8–96px, clamped) that writes `data.fontSize`; when set it overrides the width-derived auto scale (`data.fontSize ?? scaleFont(width)`), otherwise size keeps following node width. + - Node deletion (from the prior session, still active): hover/selection shows a trash button at the node's bottom-right corner; `handleDeleteNode` uses the Liveblocks `onDelete` (not the `remove` change, which is a no-op in this bundle) to remove the node + connected edges. + - `tsc --noEmit`, `eslint`, and `npm run build` all pass. - `components/editor/canvas-node.tsx`: replaced the placeholder renderer with the full `CanvasNodeRenderer`. Adds `` — handles show only on selection, min-size enforced, subtle cyan matching the dark canvas. Shape visuals (reused `ShapeOutline`) unchanged. Label stays centered (`flex items-center justify-center`); double-clicking anywhere on the node opens a `textarea` (`autoFocus`, `nopan nowheel` classes so typing never drags the node or pans the canvas, `resize-none`, centered text, dashed `brand` border, empty-label placeholder in the same centered spot). `Escape` closes via `onEndEdit`; `onBlur` closes and persists. Edit state is a single `editingId` in `CanvasFlow`, so only one node edits at a time - `components/editor/canvas.tsx`: `NODE_TYPES` module const removed — `nodeTypes` is now built in `useMemo` per render so each node gets `isEditing`, `onStartEdit`, `onChangeLabel`, `onEndEdit`. Label writes go through `NodeReplaceChange` (`type: "replace"` with `{ ...current, data: { ...current.data, label } }`) via `onNodesChange` — verified `applyChanges` in `@xyflow/react` supports the `replace` branch, so labels stay connected to the Liveblocks sync flow. Resize dimensions flow through the existing `onNodesChange` `dimensions` change path (NodeResizer dispatches them natively) - `npm run build` passes (TypeScript clean); `eslint` clean on touched files @@ -124,20 +181,91 @@ Update this file whenever the current phase, active feature, or implementation s ## In Progress -- Shape interaction fixes + text annotations/fonts (completed, verified): - - **Connection handles**: all four sides were non-functional because the handles had no `id` — `getHandle()` in `@xyflow/system` resolves a null handle id to the **first** handle (`[...source, ...target]`), so every drag attached to the bottom source handle. Fixed by giving each handle a unique `id` (`target-top`, `target-left`, `source-bottom`, `source-right`) so `getHandle` returns the correct port. In the default Loose connection mode every handle now starts and ends connections (`isConnectableStart`/`isConnectableEnd` default true). Top/Left ports are `target`, Bottom/Right are `source`. - - **Text annotations**: double-clicking empty canvas creates a free-standing `shape: "text"` node (new `CanvasNodeShape` member) centered at the cursor, no outline, no handles, freely resizable (`keepAspectRatio={false}` for text), and it auto-enters inline edit mode via `setEditingId`. Detected with `onPaneClick` timing (this React Flow fork exposes `onPaneClick` but **no** `onPaneDoubleClick`), so `handlePaneClick` tracks the last click and fires `createTextNode` within 300ms/8px. `zoomOnDoubleClick={false}` so double-click doesn't also zoom. - - **Fonts**: 11 curated fonts self-hosted via `next/font/google` in new `components/editor/canvas-fonts.ts`, applied as CSS variables on the canvas wrapper. A `NodeToolbar` above any selected node shows a font ` setDraft(event.target.value)} diff --git a/components/editor/canvas-node.tsx b/components/editor/canvas-node.tsx index 7d8fb03..11bb5d3 100644 --- a/components/editor/canvas-node.tsx +++ b/components/editor/canvas-node.tsx @@ -2,7 +2,13 @@ import { Handle, NodeResizer, Position, type NodeProps } from "@xyflow/react"; import { Trash2 } from "lucide-react"; -import { useRef, useState, type KeyboardEvent, type MouseEvent } from "react"; +import { + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type MouseEvent, +} from "react"; import type { CanvasNode, NodeColorPair } from "@/types/canvas"; import { DEFAULT_FONT_KEY, fontCssVar } from "./canvas-fonts"; @@ -51,12 +57,17 @@ interface CanvasNodeRendererProps extends NodeProps { 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, @@ -66,13 +77,43 @@ export function CanvasNodeRenderer({ 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 = 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]); + + // 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 [hovered, setHovered] = useState(false); const hoverTimerRef = useRef(null); const showHover = () => { @@ -149,36 +190,53 @@ export function CanvasNodeRenderer({ color: textColor, fontSize, lineHeight: 1.2, - overflow: "hidden", - wordBreak: "break-word", - whiteSpace: "pre-wrap", + overflow: isTextNode ? "visible" : "hidden", + whiteSpace: "pre", fontFamily, }} > {data.label}
+ {isTextNode ? ( + + {data.label} + + ) : null} {isEditing ? (