{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "realtime-cursor-tanstack", "title": "Realtime Cursors (TanStack Start)", "description": "Display realtime cursor positions of other users in a room. Perfect for collaborative applications. Includes full Convex backend with presence tracking.", "dependencies": [ "framer-motion", "convex@latest", "@auth/core", "@convex-dev/react-query@latest", "@tanstack/react-query@latest", "@convex-dev/auth@latest" ], "registryDependencies": [], "files": [ { "path": "src/registry/convex/blocks/realtime-cursor-tanstack/components/cursor.tsx", "content": "import { motion } from \"framer-motion\";\n\ninterface CursorProps {\n name: string;\n color: string;\n position: { x: number; y: number };\n}\n\nexport function Cursor({ name, color, position }: CursorProps) {\n return (\n \n \n \n \n \n {name}\n \n \n );\n}\n", "type": "registry:component", "target": "components/cursor.tsx" }, { "path": "src/registry/convex/blocks/realtime-cursor-tanstack/components/realtime-cursors.tsx", "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport { AnimatePresence } from \"framer-motion\";\nimport { Cursor } from \"./cursor\";\nimport { useRealtimeCursors } from \"../hooks/use-realtime-cursors\";\n\ninterface RealtimeCursorsProps {\n roomName: string;\n username: string;\n userColor?: string;\n children?: React.ReactNode;\n className?: string;\n}\n\nexport function RealtimeCursors({\n roomName,\n username,\n userColor,\n children,\n className,\n}: RealtimeCursorsProps) {\n const containerRef = useRef(null);\n const { cursors, createMouseMoveHandler, setContainer, isConnected } =\n useRealtimeCursors({\n roomName,\n username,\n userColor,\n });\n\n useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n // Set container reference for coordinate calculations\n setContainer(container);\n\n // Create handler that calculates container-relative positions\n const handleMove = createMouseMoveHandler(container);\n\n container.addEventListener(\"mousemove\", handleMove);\n return () => {\n container.removeEventListener(\"mousemove\", handleMove);\n setContainer(null);\n };\n }, [createMouseMoveHandler, setContainer]);\n\n return (\n \n {children}\n \n {cursors.map((cursor) => (\n \n ))}\n \n {!isConnected && (\n
\n Connecting...\n
\n )}\n \n );\n}\n", "type": "registry:component", "target": "components/realtime-cursors.tsx" }, { "path": "src/registry/convex/blocks/realtime-cursor-tanstack/hooks/use-realtime-cursors.ts", "content": "\"use client\";\n\nimport { useQuery, useMutation } from \"convex/react\";\nimport { api } from \"@/convex/_generated/api\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\ninterface CursorPosition {\n x: number;\n y: number;\n}\n\ninterface CursorData {\n position: CursorPosition;\n name: string;\n color: string;\n}\n\ninterface UseRealtimeCursorsProps {\n roomName: string;\n username: string;\n userColor?: string;\n throttleMs?: number;\n}\n\nexport interface Cursor {\n id: string;\n name: string;\n color: string;\n position: CursorPosition;\n}\n\n// Generate a unique session ID\n// Important: Do NOT use localStorage as it's shared between iframes on the same domain,\n// which would cause all instances to share the same sessionId and filter out each other's cursors\nfunction generateSessionId(): string {\n return `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;\n}\n\nexport function useRealtimeCursors({\n roomName,\n username,\n userColor = \"#3b82f6\",\n throttleMs = 100,\n}: UseRealtimeCursorsProps) {\n const [sessionId, setSessionId] = useState(\"\");\n const presenceData = useQuery(api.presence.list, { roomId: roomName });\n const updatePresence = useMutation(api.presence.update);\n const leaveRoom = useMutation(api.presence.leave);\n const lastUpdateRef = useRef(0);\n const containerRef = useRef(null);\n\n useEffect(() => {\n setSessionId(generateSessionId());\n }, []);\n\n // Filter out current user's cursor and map to Cursor type\n const cursors: Cursor[] = (presenceData ?? [])\n .filter((p: any) => {\n // Must have position data\n if (!p.data?.position) return false;\n // Filter out current user's cursor (by sessionId)\n if (p.sessionId && p.sessionId === sessionId) return false;\n return true;\n })\n .map((p: any) => ({\n id: p._id,\n name: p.data.name || \"Anonymous\",\n color: p.data.color || \"#888888\",\n position: p.data.position!,\n }));\n\n const updateCursor = useCallback(\n (position: CursorPosition) => {\n if (!sessionId) return;\n\n const now = Date.now();\n if (now - lastUpdateRef.current < throttleMs) {\n return;\n }\n lastUpdateRef.current = now;\n\n const data: CursorData = {\n position,\n name: username,\n color: userColor,\n };\n updatePresence({ roomId: roomName, data, sessionId });\n },\n [updatePresence, roomName, username, userColor, throttleMs, sessionId],\n );\n\n // Create a mouse move handler that calculates container-relative position\n const createMouseMoveHandler = useCallback(\n (container: HTMLElement) => {\n containerRef.current = container;\n return (e: any) => {\n const rect = container.getBoundingClientRect();\n // Calculate position relative to container\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n updateCursor({ x, y });\n };\n },\n [updateCursor],\n );\n\n // Set the container reference\n const setContainer = useCallback((container: any) => {\n containerRef.current = container;\n }, []);\n\n useEffect(() => {\n if (!sessionId) return;\n return () => {\n leaveRoom({ roomId: roomName, sessionId });\n };\n }, [leaveRoom, roomName, sessionId]);\n\n return {\n cursors,\n updateCursor,\n createMouseMoveHandler,\n setContainer,\n isConnected: presenceData !== undefined,\n sessionId,\n };\n}\n", "type": "registry:hook", "target": "hooks/use-realtime-cursors.ts" }, { "path": "src/registry/convex/blocks/realtime-cursor-tanstack/convex/schema.ts", "content": "import { defineSchema, defineTable } from \"convex/server\";\nimport { v } from \"convex/values\";\n\n/**\n * Schema for realtime cursor/presence tracking.\n *\n * This schema supports both authenticated and demo modes:\n * - Authenticated: userId links presence to the user\n * - Demo mode: sessionId tracks presence without authentication\n *\n * Security considerations:\n * - Presence is scoped to rooms for isolation\n * - lastSeen enables automatic cleanup of stale entries\n * - Multiple indexes for efficient lookups\n */\nexport default defineSchema({\n // Presence tracking table for cursors and user state\n presence: defineTable({\n // Room identifier for presence isolation\n roomId: v.string(),\n\n // User reference (optional for demo mode)\n userId: v.optional(v.id(\"users\")),\n\n // Session identifier for demo mode tracking\n sessionId: v.optional(v.string()),\n\n // Presence data (cursor position, status, user info)\n data: v.object({\n // Cursor/pointer position\n cursor: v.optional(v.object({ x: v.number(), y: v.number() })),\n position: v.optional(v.object({ x: v.number(), y: v.number() })),\n\n // User status (online, away, etc.)\n status: v.optional(v.string()),\n\n // Display information\n userName: v.optional(v.string()),\n userImage: v.optional(v.string()),\n name: v.optional(v.string()),\n color: v.optional(v.string()),\n }),\n\n // Timestamp for staleness detection\n lastSeen: v.number(),\n })\n .index(\"by_room\", [\"roomId\"])\n .index(\"by_user_and_room\", [\"userId\", \"roomId\"])\n .index(\"by_session_and_room\", [\"sessionId\", \"roomId\"]),\n\n // Optional: Users table if you want to track users\n // Uncomment if using with authentication\n // users: defineTable({\n // name: v.optional(v.string()),\n // email: v.optional(v.string()),\n // image: v.optional(v.string()),\n // }),\n});\n", "type": "registry:file", "target": "convex/schema.ts" }, { "path": "src/registry/convex/blocks/realtime-cursor-tanstack/convex/presence.ts", "content": "import { query, mutation, internalMutation } from \"./_generated/server\";\nimport { v } from \"convex/values\";\n\n/**\n * Presence data validator - defines allowed fields for presence data.\n */\nconst presenceDataValidator = v.object({\n cursor: v.optional(v.object({ x: v.number(), y: v.number() })),\n position: v.optional(v.object({ x: v.number(), y: v.number() })),\n status: v.optional(v.string()),\n userName: v.optional(v.string()),\n userImage: v.optional(v.string()),\n name: v.optional(v.string()),\n color: v.optional(v.string()),\n});\n\n/**\n * Full presence record validator.\n */\nconst presenceValidator = v.object({\n _id: v.id(\"presence\"),\n _creationTime: v.number(),\n roomId: v.string(),\n userId: v.optional(v.id(\"users\")),\n sessionId: v.optional(v.string()),\n data: presenceDataValidator,\n lastSeen: v.number(),\n});\n\n/**\n * Presence timeout in milliseconds.\n * Entries older than this are considered stale and filtered out.\n */\nconst PRESENCE_TIMEOUT = 30000; // 30 seconds\n\n/**\n * Maximum room ID length.\n */\nconst MAX_ROOM_ID_LENGTH = 100;\n\n/**\n * List all active presence entries in a room.\n *\n * This query is reactive - it automatically updates when presence changes.\n * Filters out stale entries (older than PRESENCE_TIMEOUT).\n */\nexport const list = query({\n args: { roomId: v.string() },\n returns: v.array(presenceValidator),\n handler: async (ctx: any, args: any) => {\n // Validate room ID\n if (!args.roomId || args.roomId.length > MAX_ROOM_ID_LENGTH) {\n return [];\n }\n\n const now = Date.now();\n const presenceList = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_room\", (q: any) => q.eq(\"roomId\", args.roomId))\n .collect();\n\n // Filter out stale presence entries\n return presenceList.filter((p: any) => now - p.lastSeen < PRESENCE_TIMEOUT);\n },\n});\n\n/**\n * Update presence (cursor position, status, etc.) in a room.\n *\n * Creates a new presence entry if one doesn't exist, otherwise updates existing.\n * Supports both authenticated and demo modes.\n *\n * Security:\n * - Requires either userId (from auth) or sessionId (demo mode)\n * - Data fields are validated\n */\nexport const update = mutation({\n args: {\n roomId: v.string(),\n data: presenceDataValidator,\n sessionId: v.optional(v.string()),\n },\n returns: v.null(),\n handler: async (ctx: any, args: any) => {\n // Validate room ID\n if (!args.roomId || args.roomId.length > MAX_ROOM_ID_LENGTH) {\n return null;\n }\n\n // Try to get authenticated user ID\n let userId: any = null;\n try {\n const { getAuthUserId } = await import(\"@convex-dev/auth/server\");\n userId = await getAuthUserId(ctx);\n } catch {\n // Auth not configured - demo mode only\n }\n\n // Require at least a sessionId if no auth\n if (!userId && !args.sessionId) {\n return null;\n }\n\n // IMPORTANT: Prioritize sessionId for lookups to support multiple cursors\n // from the same authenticated user (e.g., two browser tabs/iframes).\n // Only fall back to userId when sessionId is not provided.\n let existing = null;\n if (args.sessionId) {\n existing = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_session_and_room\", (q: any) =>\n q.eq(\"sessionId\", args.sessionId).eq(\"roomId\", args.roomId),\n )\n .first();\n } else if (userId) {\n existing = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_user_and_room\", (q: any) =>\n q.eq(\"userId\", userId).eq(\"roomId\", args.roomId),\n )\n .first();\n }\n\n if (existing) {\n // Update existing presence\n await ctx.db.patch(existing._id, {\n data: args.data,\n lastSeen: Date.now(),\n });\n } else {\n // Create new presence entry\n await ctx.db.insert(\"presence\", {\n roomId: args.roomId,\n userId: userId,\n sessionId: args.sessionId,\n data: args.data,\n lastSeen: Date.now(),\n });\n }\n\n return null;\n },\n});\n\n/**\n * Leave a room (mark presence as stale).\n *\n * Marks the presence entry with lastSeen: 0 so it's filtered out immediately.\n * The actual deletion is handled by the cleanup function.\n */\nexport const leave = mutation({\n args: {\n roomId: v.string(),\n sessionId: v.optional(v.string()),\n },\n returns: v.null(),\n handler: async (ctx: any, args: any) => {\n // Try to get authenticated user ID\n let userId: any = null;\n try {\n const { getAuthUserId } = await import(\"@convex-dev/auth/server\");\n userId = await getAuthUserId(ctx);\n } catch {\n // Auth not configured\n }\n\n // Prioritize sessionId for lookups (same as update)\n let existing = null;\n if (args.sessionId) {\n existing = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_session_and_room\", (q: any) =>\n q.eq(\"sessionId\", args.sessionId).eq(\"roomId\", args.roomId),\n )\n .first();\n } else if (userId) {\n existing = await ctx.db\n .query(\"presence\")\n .withIndex(\"by_user_and_room\", (q: any) =>\n q.eq(\"userId\", userId).eq(\"roomId\", args.roomId),\n )\n .first();\n }\n\n if (existing) {\n // Mark as stale instead of deleting to avoid race conditions\n await ctx.db.patch(existing._id, { lastSeen: 0 });\n }\n\n return null;\n },\n});\n\n/**\n * Cleanup stale presence entries.\n *\n * This is an internal mutation meant to be called from a cron job.\n * Deletes all presence entries older than PRESENCE_TIMEOUT.\n */\nexport const cleanup = internalMutation({\n args: {},\n returns: v.number(),\n handler: async (ctx: any) => {\n const cutoff = Date.now() - PRESENCE_TIMEOUT;\n const stalePresence = await ctx.db\n .query(\"presence\")\n .filter((q: any) => q.lt(q.field(\"lastSeen\"), cutoff))\n .collect();\n\n // Delete sequentially to avoid write conflicts\n for (const p of stalePresence) {\n await ctx.db.delete(p._id);\n }\n\n return stalePresence.length;\n },\n});\n", "type": "registry:file", "target": "convex/presence.ts" }, { "path": "src/registry/convex/clients/tanstack/lib/convex/client.ts", "content": "import { ConvexReactClient } from \"convex/react\";\n\nconst convexUrl = (import.meta as any).env.VITE_CONVEX_URL as string;\n\nexport const convex = new ConvexReactClient(convexUrl);\n", "type": "registry:lib", "target": "lib/convex/client.ts" }, { "path": "src/registry/convex/clients/tanstack/lib/convex/provider.tsx", "content": "import { ConvexAuthProvider } from \"@convex-dev/auth/react\";\nimport { ConvexReactClient } from \"convex/react\";\nimport { ReactNode } from \"react\";\n\nconst convex = new ConvexReactClient(\n (import.meta as any).env.VITE_CONVEX_URL as string,\n);\n\nexport function ConvexClientProvider({ children }: { children: ReactNode }) {\n return {children};\n}\n", "type": "registry:lib", "target": "lib/convex/provider.tsx" }, { "path": "src/registry/convex/clients/tanstack/lib/convex/server.ts", "content": "import { convexQuery, useConvexMutation } from \"@convex-dev/react-query\";\nimport { api } from \"@/convex/_generated/api\";\n\nexport { convexQuery, useConvexMutation, api };\n", "type": "registry:lib", "target": "lib/convex/server.ts" } ], "envVars": { "CONVEX_DEPLOYMENT": "", "VITE_CONVEX_URL": "" }, "docs": "## Post-Install Setup\n\n1. Run `npx convex dev` to start the Convex dev server.\n\n2. Copy the deployment URL to your `.env` file as `VITE_CONVEX_URL`.\n\n3. The Convex schema and functions will be automatically deployed when you run `npx convex dev`.\n\n## Post-Install Setup\n\n1. Run `npx convex dev` to start the Convex dev server and get your deployment URL.\n\n2. Copy the deployment URL to your `.env` file as `VITE_CONVEX_URL`.\n\n3. Wrap your app with the `ConvexAuthProvider` from `lib/convex/provider.tsx`.", "type": "registry:component" }