{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "realtime-cursor-nuxtjs", "type": "registry:component", "title": "Realtime Cursor for Nuxt and Supabase", "description": "Component which renders realtime cursors from other users in a room.", "dependencies": [ "@supabase/supabase-js@latest", "@vueuse/core", "lucide-vue-next" ], "files": [ { "path": "registry/default/realtime-cursor/nuxtjs/app/components/cursor.vue", "content": "\n\n\n", "type": "registry:component", "target": "app/components/cursor.vue" }, { "path": "registry/default/realtime-cursor/nuxtjs/app/components/realtime-cursors.vue", "content": "\n\n\n", "type": "registry:component", "target": "app/components/realtime-cursors.vue" }, { "path": "registry/default/realtime-cursor/nuxtjs/app/composables/useRealtimeCursors.ts", "content": "import { REALTIME_SUBSCRIBE_STATES, type RealtimeChannel } from '@supabase/supabase-js'\nimport { onMounted, onUnmounted, reactive, ref } from 'vue'\n\n// @ts-ignore\nimport { createClient } from '@/lib/supabase/client'\n\n/**\n * Throttle a callback to a certain delay.\n * It will only call the callback if the delay has passed,\n * using the arguments from the last call.\n */\nfunction useThrottleCallback(\n callback: (...args: Params) => void,\n delay: number\n) {\n let lastCall = 0\n let timeout: ReturnType | null = null\n\n const run = (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall)\n\n if (remainingTime <= 0) {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n lastCall = now\n callback(...args)\n } else if (!timeout) {\n timeout = setTimeout(() => {\n lastCall = Date.now()\n timeout = null\n callback(...args)\n }, remainingTime)\n }\n }\n\n const cancel = () => {\n if (timeout) {\n clearTimeout(timeout)\n timeout = null\n }\n }\n\n return { run, cancel }\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\nexport type CursorEventPayload = {\n position: { x: number; y: number }\n user: { id: number; name: string }\n color: string\n timestamp: number\n}\n\nexport function useRealtimeCursors({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) {\n const color = generateRandomColor()\n const userId = generateRandomNumber()\n\n const cursors = reactive>({})\n const cursorPayload = ref(null)\n const channelRef = ref(null)\n\n const sendCursor = (event: MouseEvent) => {\n const payload: CursorEventPayload = {\n position: {\n x: event.clientX,\n y: event.clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color,\n timestamp: Date.now(),\n }\n\n cursorPayload.value = payload\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload,\n })\n }\n\n const { run: handleMouseMove, cancel: cancelThrottle } = useThrottleCallback(\n sendCursor,\n throttleMs\n )\n\n onMounted(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('system', {}, (payload: CursorEventPayload) => {\n console.error('Realtime system error:', payload)\n\n // Defensive cleanup\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n })\n .on(\n 'presence',\n { event: 'leave' },\n ({ leftPresences }: { leftPresences: Array<{ key: string }> }) => {\n leftPresences.forEach(({ key }) => {\n delete cursors[key]\n })\n }\n )\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.value) return\n\n channelRef.value?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.value,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, ({ payload }: { payload: CursorEventPayload }) => {\n if (payload.user.id === userId) return\n\n cursors[payload.user.id] = payload\n })\n .subscribe(async (status: REALTIME_SUBSCRIBE_STATES) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n try {\n await channel.track({ key: userId })\n channelRef.value = channel\n } catch (err) {\n console.error('Failed to track presence for current user:', err)\n channelRef.value = null\n }\n } else {\n Object.keys(cursors).forEach((k) => delete cursors[k])\n channelRef.value = null\n }\n })\n\n window.addEventListener('mousemove', handleMouseMove)\n })\n\n onUnmounted(() => {\n window.removeEventListener('mousemove', handleMouseMove)\n\n cancelThrottle()\n\n if (channelRef.value) {\n channelRef.value.unsubscribe()\n channelRef.value = null\n }\n\n Object.keys(cursors).forEach((k) => delete cursors[k])\n })\n\n return { cursors }\n}\n", "type": "registry:component", "target": "app/composables/useRealtimeCursors.ts" } ] }