From 9fe79d571d29024a7a8a927dacdaa6f8d81bc4f6 Mon Sep 17 00:00:00 2001 From: tejasM17 Date: Thu, 16 Jul 2026 22:59:39 +0530 Subject: [PATCH 1/2] compleated 18-feat --- components/editor/canvas.tsx | 81 +++- components/editor/starter-templates-modal.tsx | 373 ++++++++++++++++++ components/editor/starter-templates.ts | 252 ++++++++++++ components/editor/workspace-shell.tsx | 34 +- context/feature-specs/18-starter-templates.md | 59 +++ context/feature-specs/image/image.png | Bin 0 -> 345755 bytes context/progress-tracker.md | 22 +- 7 files changed, 805 insertions(+), 16 deletions(-) create mode 100644 components/editor/starter-templates-modal.tsx create mode 100644 components/editor/starter-templates.ts create mode 100644 context/feature-specs/18-starter-templates.md create mode 100644 context/feature-specs/image/image.png diff --git a/components/editor/canvas.tsx b/components/editor/canvas.tsx index 83d1967..985e33d 100644 --- a/components/editor/canvas.tsx +++ b/components/editor/canvas.tsx @@ -45,6 +45,11 @@ import { ShapePanel, type ShapeDragPayload } from "./shape-panel"; import { CanvasNodeRenderer } from "./canvas-node"; import { CanvasEdgeRenderer } from "./canvas-edge"; import { CanvasControls } from "./canvas-controls"; +import { StarterTemplatesModal } from "./starter-templates-modal"; +import { + cloneTemplateWithFreshIds, + type CanvasTemplate, +} from "./starter-templates"; /** Default style for newly created edges — light stroke, rounded ends, arrow. */ const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = { @@ -67,6 +72,9 @@ const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = { interface CanvasProps { roomId: string; + /** Controlled open state for the starter templates import modal. */ + templatesOpen?: boolean; + onTemplatesOpenChange?: (open: boolean) => void; } /** @@ -77,7 +85,11 @@ interface CanvasProps { * the foundation of the editor — node/edge rendering and persistence * land in later features. */ -export function Canvas({ roomId }: CanvasProps) { +export function Canvas({ + roomId, + templatesOpen = false, + onTemplatesOpenChange, +}: CanvasProps) { return ( }> - + @@ -155,13 +170,21 @@ function generateNodeId(shape: NodeShape): string { return `${shape}-${timestamp}-${nodeIdCounter}`; } +interface CollaborativeFlowProps { + templatesOpen: boolean; + onTemplatesOpenChange?: (open: boolean) => void; +} + /** * The actual React Flow surface, rendered after Liveblocks has connected * and the storage layer is ready (via Suspense). */ -function CollaborativeFlow() { +function CollaborativeFlow({ + templatesOpen, + onTemplatesOpenChange, +}: CollaborativeFlowProps) { const wrapperRef = useRef(null); - const { screenToFlowPosition } = useReactFlow(); + const { screenToFlowPosition, fitView } = useReactFlow(); // `useLiveblocksFlow` returns the synced nodes/edges plus change // handlers. With `suspense: true`, `nodes` and `edges` are guaranteed @@ -187,6 +210,48 @@ function CollaborativeFlow() { [lbOnNodesChange], ); + /** + * Replace the entire collaborative graph with a starter template. + * Clears existing nodes/edges first, then adds the template graph and + * fits the view once the new elements are in place. + */ + const handleImportTemplate = useCallback( + (template: CanvasTemplate) => { + const { nodes: nextNodes, edges: nextEdges } = + cloneTemplateWithFreshIds(template); + + // Remove every existing edge, then every existing node. + if (edges.length > 0) { + onEdgesChange( + edges.map((edge) => ({ type: "remove" as const, id: edge.id })), + ); + } + if (nodes.length > 0) { + onNodesChange( + nodes.map((node) => ({ type: "remove" as const, id: node.id })), + ); + } + + // Add template graph into collaborative storage. + if (nextNodes.length > 0) { + onNodesChange( + nextNodes.map((node) => ({ type: "add" as const, item: node })), + ); + } + if (nextEdges.length > 0) { + onEdgesChange( + nextEdges.map((edge) => ({ type: "add" as const, item: edge })), + ); + } + + // Fit after React Flow has applied the new graph. + window.setTimeout(() => { + void fitView({ duration: 200, padding: 0.15 }); + }, 50); + }, + [edges, nodes, onEdgesChange, onNodesChange, fitView], + ); + // Memoized type maps — stable refs so React Flow does not remount nodes/edges const nodeTypes = useMemo( () => ({ @@ -291,6 +356,14 @@ function CollaborativeFlow() { {/* Zoom + undo/redo bar (bottom-left); shape palette (bottom-center) */} + + {onTemplatesOpenChange ? ( + + ) : null} ); } diff --git a/components/editor/starter-templates-modal.tsx b/components/editor/starter-templates-modal.tsx new file mode 100644 index 0000000..64f002e --- /dev/null +++ b/components/editor/starter-templates-modal.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { Download } from "lucide-react"; + +import { DialogPattern } from "@/components/editor/dialog-pattern"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { NODE_COLORS } from "@/types/canvas"; +import type { CanvasNode, NodeShape } from "@/types/canvas"; +import { + CANVAS_TEMPLATES, + type CanvasTemplate, +} from "@/components/editor/starter-templates"; + +interface StarterTemplatesModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImport: (template: CanvasTemplate) => void; +} + +/** + * Dialog that lists built-in starter templates as cards with a lightweight + * diagram preview. Choosing Import calls `onImport` then closes the modal. + */ +export function StarterTemplatesModal({ + open, + onOpenChange, + onImport, +}: StarterTemplatesModalProps) { + const handleImport = (template: CanvasTemplate) => { + onImport(template); + onOpenChange(false); + }; + + return ( + + +
+ {CANVAS_TEMPLATES.map((template) => ( + handleImport(template)} + /> + ))} +
+
+
+ ); +} + +interface TemplateCardProps { + template: CanvasTemplate; + onImport: () => void; +} + +function TemplateCard({ template, onImport }: TemplateCardProps) { + return ( +
+
+ +
+
+
+

+ {template.name} +

+

+ {template.description} +

+
+ +
+
+ ); +} + +/** Fixed preview viewport size (CSS px). */ +const PREVIEW_WIDTH = 260; +const PREVIEW_HEIGHT = 140; +const PREVIEW_PADDING = 16; + +/** + * Lightweight SVG diagram preview — no React Flow instance. + * Fits template node bounds into a fixed viewport and draws edges + shapes. + */ +function TemplatePreview({ template }: { template: CanvasTemplate }) { + const bounds = computeBounds(template.nodes); + const contentW = Math.max(bounds.width, 1); + const contentH = Math.max(bounds.height, 1); + + const scale = Math.min( + (PREVIEW_WIDTH - PREVIEW_PADDING * 2) / contentW, + (PREVIEW_HEIGHT - PREVIEW_PADDING * 2) / contentH, + ); + + const offsetX = + (PREVIEW_WIDTH - contentW * scale) / 2 - bounds.minX * scale; + const offsetY = + (PREVIEW_HEIGHT - contentH * scale) / 2 - bounds.minY * scale; + + const nodeMap = new Map(template.nodes.map((node) => [node.id, node])); + + return ( + + {/* Edges as simple lines between node centers */} + {template.edges.map((edge) => { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) return null; + + const s = nodeCenter(source, scale, offsetX, offsetY); + const t = nodeCenter(target, scale, offsetX, offsetY); + + return ( + + ); + })} + + {/* Nodes using shape + palette fill */} + {template.nodes.map((node) => { + const color = + NODE_COLORS.find((c) => c.name === node.data.color) ?? + NODE_COLORS[0]; + const w = (node.width ?? 160) * scale; + const h = (node.height ?? 80) * scale; + const x = node.position.x * scale + offsetX; + const y = node.position.y * scale + offsetY; + + return ( + + ); + })} + + ); +} + +interface Bounds { + minX: number; + minY: number; + width: number; + height: number; +} + +function computeBounds(nodes: CanvasNode[]): Bounds { + if (nodes.length === 0) { + return { minX: 0, minY: 0, width: 1, height: 1 }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const node of nodes) { + const w = node.width ?? 160; + const h = node.height ?? 80; + 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); + } + + return { + minX, + minY, + width: maxX - minX, + height: maxY - minY, + }; +} + +function nodeCenter( + node: CanvasNode, + scale: number, + offsetX: number, + offsetY: number, +): { x: number; y: number } { + const w = (node.width ?? 160) * scale; + const h = (node.height ?? 80) * scale; + return { + x: node.position.x * scale + offsetX + w / 2, + y: node.position.y * scale + offsetY + h / 2, + }; +} + +interface PreviewShapeProps { + shape: NodeShape; + x: number; + y: number; + width: number; + height: number; + fill: string; + stroke: string; +} + +/** + * Draw a simplified node silhouette for the card preview. + * Shapes mirror the canvas vocabulary without labels or handles. + */ +function PreviewShape({ + shape, + x, + y, + width, + height, + fill, + stroke, +}: PreviewShapeProps) { + const strokeWidth = 1.25; + + if (shape === "circle") { + return ( + + ); + } + + if (shape === "pill") { + const r = Math.min(width, height) / 2; + return ( + + ); + } + + if (shape === "diamond") { + const cx = x + width / 2; + const cy = y + height / 2; + const points = [ + `${cx},${y}`, + `${x + width},${cy}`, + `${cx},${y + height}`, + `${x},${cy}`, + ].join(" "); + return ( + + ); + } + + if (shape === "hexagon") { + const inset = width * 0.22; + const points = [ + `${x + inset},${y}`, + `${x + width - inset},${y}`, + `${x + width},${y + height / 2}`, + `${x + width - inset},${y + height}`, + `${x + inset},${y + height}`, + `${x},${y + height / 2}`, + ].join(" "); + return ( + + ); + } + + if (shape === "cylinder") { + const cx = x + width / 2; + const rx = width / 2; + const ry = Math.min(height * 0.14, 8); + const topY = y + ry; + const bottomY = y + height - ry; + + return ( + + + + + ); + } + + // rectangle (default) + return ( + + ); +} diff --git a/components/editor/starter-templates.ts b/components/editor/starter-templates.ts new file mode 100644 index 0000000..68f0344 --- /dev/null +++ b/components/editor/starter-templates.ts @@ -0,0 +1,252 @@ +import type { + CanvasEdge, + CanvasNode, + NodeColorName, + NodeShape, +} from "@/types/canvas"; + +/** + * A predefined canvas diagram users can import to replace the current + * collaborative graph. + */ +export interface CanvasTemplate { + id: string; + name: string; + description: string; + nodes: CanvasNode[]; + edges: CanvasEdge[]; +} + +/** Default dimensions aligned with the shape panel. */ +const SIZE: Record = { + rectangle: { width: 180, height: 72 }, + diamond: { width: 110, height: 110 }, + circle: { width: 96, height: 96 }, + pill: { width: 150, height: 56 }, + cylinder: { width: 100, height: 110 }, + hexagon: { width: 130, height: 100 }, +}; + +/** + * Build a single canvas node with sensible defaults for template data. + */ +function n( + id: string, + label: string, + x: number, + y: number, + shape: NodeShape, + color: NodeColorName, + size?: { width: number; height: number }, +): CanvasNode { + const dims = size ?? SIZE[shape]; + return { + id, + type: "canvasNode", + position: { x, y }, + width: dims.width, + height: dims.height, + data: { label, color, shape }, + }; +} + +/** + * Build a canvas edge between two template nodes. + */ +function e( + id: string, + source: string, + target: string, + label?: string, +): CanvasEdge { + return { + id, + type: "canvasEdge", + source, + target, + data: { label: label ?? "" }, + }; +} + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +const microservices: CanvasTemplate = { + id: "microservices", + name: "Microservices", + description: + "API Gateway routes traffic to isolated services, each backed by a dedicated database and connected via a shared message bus.", + nodes: [ + n("ms-client", "Client", 0, 140, "pill", "blue"), + n("ms-gateway", "API Gateway", 220, 130, "rectangle", "green", { + width: 170, + height: 72, + }), + n("ms-auth", "Auth Service", 480, 0, "rectangle", "purple", { + width: 160, + height: 64, + }), + n("ms-user", "User Service", 480, 100, "rectangle", "purple", { + width: 160, + height: 64, + }), + n("ms-order", "Order Service", 480, 200, "rectangle", "purple", { + width: 160, + height: 64, + }), + n("ms-payment", "Payment Service", 480, 300, "rectangle", "purple", { + width: 160, + height: 64, + }), + n("ms-auth-db", "Auth DB", 720, 0, "cylinder", "neutral"), + n("ms-user-db", "User DB", 720, 100, "cylinder", "neutral"), + n("ms-order-db", "Order DB", 720, 200, "cylinder", "neutral"), + n("ms-payment-db", "Payment DB", 720, 300, "cylinder", "neutral"), + n("ms-bus", "Message Bus", 480, 420, "hexagon", "purple", { + width: 150, + height: 110, + }), + ], + edges: [ + e("ms-e1", "ms-client", "ms-gateway"), + e("ms-e2", "ms-gateway", "ms-auth"), + e("ms-e3", "ms-gateway", "ms-user"), + e("ms-e4", "ms-gateway", "ms-order"), + e("ms-e5", "ms-gateway", "ms-payment"), + e("ms-e6", "ms-auth", "ms-auth-db"), + e("ms-e7", "ms-user", "ms-user-db"), + e("ms-e8", "ms-order", "ms-order-db"), + e("ms-e9", "ms-payment", "ms-payment-db"), + e("ms-e10", "ms-auth", "ms-bus"), + e("ms-e11", "ms-user", "ms-bus"), + e("ms-e12", "ms-order", "ms-bus"), + e("ms-e13", "ms-payment", "ms-bus"), + ], +}; + +const cicdPipeline: CanvasTemplate = { + id: "cicd-pipeline", + name: "CI/CD Pipeline", + description: + "End-to-end delivery from source commit through build, test, containerisation, and staged deployment to production.", + nodes: [ + n("ci-source", "Source", 0, 40, "pill", "blue", { + width: 120, + height: 52, + }), + n("ci-build", "Build", 180, 40, "rectangle", "green", { + width: 110, + height: 56, + }), + n("ci-test", "Test", 350, 40, "rectangle", "green", { + width: 110, + height: 56, + }), + n("ci-container", "Containerise", 520, 40, "rectangle", "purple", { + width: 140, + height: 56, + }), + n("ci-staging", "Staging", 720, 40, "rectangle", "orange", { + width: 120, + height: 56, + }), + n("ci-deploy", "Deploy", 900, 20, "diamond", "red", { + width: 100, + height: 100, + }), + n("ci-prod", "Production", 1060, 40, "pill", "green", { + width: 140, + height: 52, + }), + ], + edges: [ + e("ci-e1", "ci-source", "ci-build"), + e("ci-e2", "ci-build", "ci-test"), + e("ci-e3", "ci-test", "ci-container"), + e("ci-e4", "ci-container", "ci-staging"), + e("ci-e5", "ci-staging", "ci-deploy"), + e("ci-e6", "ci-deploy", "ci-prod"), + ], +}; + +const eventDriven: CanvasTemplate = { + id: "event-driven", + name: "Event-Driven System", + description: + "Producers publish events to a central bus. Independent consumers handle emails, push notifications, analytics, and error queues.", + nodes: [ + n("ed-api", "API Service", 0, 0, "pill", "blue"), + n("ed-worker", "Background Worker", 0, 120, "pill", "blue"), + n("ed-webhook", "Webhook Ingest", 0, 240, "pill", "blue"), + n("ed-bus", "Event Bus", 280, 100, "hexagon", "purple", { + width: 160, + height: 130, + }), + n("ed-email", "Email Consumer", 560, 0, "rectangle", "green", { + width: 160, + height: 60, + }), + n("ed-push", "Push Notifications", 560, 90, "rectangle", "teal", { + width: 160, + height: 60, + }), + n("ed-analytics", "Analytics", 560, 180, "rectangle", "green", { + width: 160, + height: 60, + }), + n("ed-dlq", "Dead Letter Queue", 560, 280, "rectangle", "red", { + width: 160, + height: 60, + }), + ], + edges: [ + e("ed-e1", "ed-api", "ed-bus"), + e("ed-e2", "ed-worker", "ed-bus"), + e("ed-e3", "ed-webhook", "ed-bus"), + e("ed-e4", "ed-bus", "ed-email"), + e("ed-e5", "ed-bus", "ed-push"), + e("ed-e6", "ed-bus", "ed-analytics"), + e("ed-e7", "ed-bus", "ed-dlq"), + ], +}; + +/** All built-in starter templates available in the import modal. */ +export const CANVAS_TEMPLATES: CanvasTemplate[] = [ + microservices, + cicdPipeline, + eventDriven, +]; + +/** + * Clone a template with fresh node/edge IDs so re-imports never collide + * with residual graph state. Edge source/target are remapped accordingly. + */ +export function cloneTemplateWithFreshIds(template: CanvasTemplate): { + nodes: CanvasNode[]; + edges: CanvasEdge[]; +} { + const stamp = Date.now(); + const idMap = new Map(); + + const nodes: CanvasNode[] = template.nodes.map((node, index) => { + const newId = `${template.id}-${stamp}-${index}`; + idMap.set(node.id, newId); + return { + ...node, + id: newId, + position: { ...node.position }, + data: { ...node.data }, + }; + }); + + const edges: CanvasEdge[] = template.edges.map((edge, index) => ({ + ...edge, + id: `${template.id}-edge-${stamp}-${index}`, + source: idMap.get(edge.source) ?? edge.source, + target: idMap.get(edge.target) ?? edge.target, + data: edge.data ? { ...edge.data } : { label: "" }, + })); + + return { nodes, edges }; +} diff --git a/components/editor/workspace-shell.tsx b/components/editor/workspace-shell.tsx index 1f142e8..7023356 100644 --- a/components/editor/workspace-shell.tsx +++ b/components/editor/workspace-shell.tsx @@ -2,7 +2,16 @@ import { useState } from "react"; import { UserButton } from "@clerk/nextjs"; -import { PanelLeftClose, PanelLeftOpen, Share2, X, Network, Sparkles, Bot } from "lucide-react"; +import { + PanelLeftClose, + PanelLeftOpen, + Share2, + X, + Network, + Sparkles, + Bot, + LayoutTemplate, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -33,6 +42,7 @@ export function WorkspaceShell({ }: WorkspaceShellProps) { const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isAiSidebarOpen, setIsAiSidebarOpen] = useState(false); + const [templatesOpen, setTemplatesOpen] = useState(false); // Determine if current user is owner by checking against the projects list const isOwner = project @@ -84,10 +94,15 @@ export function WorkspaceShell({ onToggleAiSidebar={() => setIsAiSidebarOpen((open) => !open)} aiSidebarOpen={isAiSidebarOpen} onShareClick={shareDialog.onOpenChange} + onTemplatesClick={() => setTemplatesOpen(true)} />
- +
void; onToggleAiSidebar: () => void; onShareClick: (open: boolean) => void; + /** Opens the starter templates import modal. Omitted when canvas is unavailable. */ + onTemplatesClick?: () => void; } function EditorNavbar({ @@ -138,6 +155,7 @@ function EditorNavbar({ onToggleSidebar, onToggleAiSidebar, onShareClick, + onTemplatesClick, }: EditorNavbarProps) { return (
@@ -167,6 +185,18 @@ function EditorNavbar({
+ {onTemplatesClick ? ( + + ) : null}
-
-
+
diff --git a/components/editor/editor-shell.tsx b/components/editor/editor-shell.tsx index 4329d97..ac62dff 100644 --- a/components/editor/editor-shell.tsx +++ b/components/editor/editor-shell.tsx @@ -43,24 +43,24 @@ export function EditorShell({ initialOwned, initialShared }: EditorShellProps) { const isDeleteOpen = dialog.kind === "delete"; return ( -
+
+
+ +
setIsSidebarOpen((open) => !open)} /> -
- setIsSidebarOpen(false)} - ownedProjects={ownedProjects} - sharedProjects={sharedProjects} - onCreateProject={openCreate} - onRenameProject={openRename} - onDeleteProject={openDelete} - onOpenProject={(projectId) => router.push(`/editor/${projectId}`)} - /> - -
+ setIsSidebarOpen(false)} + ownedProjects={ownedProjects} + sharedProjects={sharedProjects} + onCreateProject={openCreate} + onRenameProject={openRename} + onDeleteProject={openDelete} + onOpenProject={(projectId) => router.push(`/editor/${projectId}`)} + /> + {others.map((other) => { + const cursor = other.presence.cursor; + if (!cursor) return null; + + const screenX = cursor.x * zoom + vx; + const screenY = cursor.y * zoom + vy; + const color = other.info.color; + const name = other.info.name || "Collaborator"; + + return ( +
+ +
+ {name} +
+
+ ); + })} +
+ ); +} + +/** + * Hook: broadcast cursor in flow coordinates on React Flow pointer events. + */ +export function useCursorPresence() { + const updateMyPresence = useUpdateMyPresence(); + const { screenToFlowPosition } = useReactFlow(); + + const onPointerMove = useCallback( + (event: MouseEvent) => { + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + updateMyPresence({ + cursor: { x: position.x, y: position.y }, + }); + }, + [screenToFlowPosition, updateMyPresence], + ); + + const onPointerLeave = useCallback(() => { + updateMyPresence({ cursor: null }); + }, [updateMyPresence]); + + return { onPointerMove, onPointerLeave }; +} + +function CursorPointer({ color }: { color: string }) { + return ( + + + + ); +} diff --git a/components/editor/presence-avatars.tsx b/components/editor/presence-avatars.tsx new file mode 100644 index 0000000..f63d7fe --- /dev/null +++ b/components/editor/presence-avatars.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { UserButton, useUser } from "@clerk/nextjs"; +import { useOthers } from "@liveblocks/react/suspense"; + +import { cn } from "@/lib/utils"; + +const MAX_VISIBLE = 5; +const AVATAR_SIZE = "h-9 w-9"; + +/** + * Top-right presence group for the editor canvas room only. + * Collaborator avatars (excluding the current Clerk user) + Clerk UserButton. + */ +export function PresenceAvatars() { + const { user } = useUser(); + const others = useOthers(); + + const currentUserId = user?.id ?? null; + + const collaborators = others.filter((other) => { + if (!currentUserId) return true; + return other.id !== currentUserId; + }); + + const visible = collaborators.slice(0, MAX_VISIBLE); + const overflow = collaborators.length - MAX_VISIBLE; + const hasCollaborators = collaborators.length > 0; + + return ( +
+ {hasCollaborators ? ( +
+
+ {visible.map((other) => ( + + ))} + {overflow > 0 ? ( +
+ +{overflow} +
+ ) : null} +
+
+
+ ) : null} + +
+ +
+
+ ); +} + +interface CollaboratorAvatarProps { + name: string; + avatar: string; + color: string; +} + +function CollaboratorAvatar({ name, avatar, color }: CollaboratorAvatarProps) { + const initials = getInitials(name); + + return ( +
+ {avatar ? ( + // eslint-disable-next-line @next/next/no-img-element -- Liveblocks avatar URLs are external + + ) : ( + + {initials} + + )} +
+ ); +} + +function getInitials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length === 0) return "?"; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} diff --git a/components/editor/project-sidebar.tsx b/components/editor/project-sidebar.tsx index 4772b97..7e48c5e 100644 --- a/components/editor/project-sidebar.tsx +++ b/components/editor/project-sidebar.tsx @@ -48,8 +48,8 @@ export function ProjectSidebar({
); @@ -270,8 +261,10 @@ function ProjectSidebar({