diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index dfd5253..20de5a4 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useSearchParams } from 'next/navigation'; +import { Suspense } from 'react'; const loginSchema = z.object({ email: z.string().email('Invalid email address'), @@ -18,7 +19,7 @@ const loginSchema = z.object({ type LoginForm = z.infer; -export default function LoginPage() { +function LoginContent() { const searchParams = useSearchParams(); const registered = searchParams.get('registered'); @@ -119,3 +120,18 @@ export default function LoginPage() { ); } + +export default function LoginPage() { + return ( + +
+
+

Loading...

+
+ + }> + +
+ ); +} diff --git a/app/(dashboard)/projects/[id]/kanban/page.tsx b/app/(dashboard)/projects/[id]/kanban/page.tsx index 6fab0f7..8c0633d 100644 --- a/app/(dashboard)/projects/[id]/kanban/page.tsx +++ b/app/(dashboard)/projects/[id]/kanban/page.tsx @@ -12,7 +12,7 @@ import { useState, useMemo, useEffect } from 'react'; import { useEpics } from '@/lib/hooks/use-epics'; import { useStories } from '@/lib/hooks/use-stories'; import { useTasks } from '@/lib/hooks/use-tasks'; -import { useSignalRContext } from '@/lib/signalr/SignalRContext'; +import { useSignalREvents, useSignalRConnection } from '@/lib/signalr/SignalRContext'; import { useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Plus, Loader2 } from 'lucide-react'; @@ -33,7 +33,7 @@ type WorkItemType = 'Epic' | 'Story' | 'Task'; interface KanbanWorkItem { id: string; title: string; - description: string; + description?: string; // Optional to match API response status: string; priority: string; type: WorkItemType; @@ -66,165 +66,59 @@ export default function KanbanPage() { // SignalR real-time updates const queryClient = useQueryClient(); - const { service, isConnected } = useSignalRContext(); + const { isConnected } = useSignalRConnection(); - // Subscribe to SignalR events for real-time updates - useEffect(() => { - if (!isConnected || !service) { - console.log('[Kanban] SignalR not connected, skipping event subscription'); - return; - } + // Subscribe to SignalR events for real-time updates (Simplified with useSignalREvents) + useSignalREvents( + { + // Epic events (6 events) + 'EpicCreated': (event: any) => { + console.log('[Kanban] Epic created:', event); + queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); + }, + 'EpicUpdated': (event: any) => { + console.log('[Kanban] Epic updated:', event); + queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); + }, + 'EpicDeleted': (event: any) => { + console.log('[Kanban] Epic deleted:', event); + queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); + }, - const handlers = service.getEventHandlers(); - if (!handlers) { - console.log('[Kanban] No event handlers available'); - return; - } + // Story events (3 events) + 'StoryCreated': (event: any) => { + console.log('[Kanban] Story created:', event); + queryClient.invalidateQueries({ queryKey: ['stories'] }); + }, + 'StoryUpdated': (event: any) => { + console.log('[Kanban] Story updated:', event); + queryClient.invalidateQueries({ queryKey: ['stories'] }); + }, + 'StoryDeleted': (event: any) => { + console.log('[Kanban] Story deleted:', event); + queryClient.invalidateQueries({ queryKey: ['stories'] }); + }, - console.log('[Kanban] Subscribing to SignalR events...'); - - // Epic events (6 events) - const unsubEpicCreated = handlers.subscribe('epic:created', (event: any) => { - console.log('[Kanban] Epic created:', event); - queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); - }); - - const unsubEpicUpdated = handlers.subscribe('epic:updated', (event: any) => { - console.log('[Kanban] Epic updated:', event); - queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); - }); - - const unsubEpicDeleted = handlers.subscribe('epic:deleted', (event: any) => { - console.log('[Kanban] Epic deleted:', event); - queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); - }); - - const unsubEpicStatusChanged = handlers.subscribe('epic:statusChanged', (event: any) => { - console.log('[Kanban] Epic status changed:', event); - // Optimistic update - queryClient.setQueryData(['epics', projectId], (old: any) => { - if (!old) return old; - return old.map((epic: any) => - epic.id === event.epicId ? { ...epic, status: event.newStatus } : epic - ); - }); - }); - - const unsubEpicAssigned = handlers.subscribe('epic:assigned', (event: any) => { - console.log('[Kanban] Epic assigned:', event); - queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); - }); - - const unsubEpicUnassigned = handlers.subscribe('epic:unassigned', (event: any) => { - console.log('[Kanban] Epic unassigned:', event); - queryClient.invalidateQueries({ queryKey: ['epics', projectId] }); - }); - - // Story events (6 events) - const unsubStoryCreated = handlers.subscribe('story:created', (event: any) => { - console.log('[Kanban] Story created:', event); - queryClient.invalidateQueries({ queryKey: ['stories'] }); - }); - - const unsubStoryUpdated = handlers.subscribe('story:updated', (event: any) => { - console.log('[Kanban] Story updated:', event); - queryClient.invalidateQueries({ queryKey: ['stories'] }); - }); - - const unsubStoryDeleted = handlers.subscribe('story:deleted', (event: any) => { - console.log('[Kanban] Story deleted:', event); - queryClient.invalidateQueries({ queryKey: ['stories'] }); - }); - - const unsubStoryStatusChanged = handlers.subscribe('story:statusChanged', (event: any) => { - console.log('[Kanban] Story status changed:', event); - // Optimistic update - queryClient.setQueryData(['stories'], (old: any) => { - if (!old) return old; - return old.map((story: any) => - story.id === event.storyId ? { ...story, status: event.newStatus } : story - ); - }); - }); - - const unsubStoryAssigned = handlers.subscribe('story:assigned', (event: any) => { - console.log('[Kanban] Story assigned:', event); - queryClient.invalidateQueries({ queryKey: ['stories'] }); - }); - - const unsubStoryUnassigned = handlers.subscribe('story:unassigned', (event: any) => { - console.log('[Kanban] Story unassigned:', event); - queryClient.invalidateQueries({ queryKey: ['stories'] }); - }); - - // Task events (7 events) - const unsubTaskCreated = handlers.subscribe('task:created', (event: any) => { - console.log('[Kanban] Task created:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }); - - const unsubTaskUpdated = handlers.subscribe('task:updated', (event: any) => { - console.log('[Kanban] Task updated:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }); - - const unsubTaskDeleted = handlers.subscribe('task:deleted', (event: any) => { - console.log('[Kanban] Task deleted:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }); - - const unsubTaskStatusChanged = handlers.subscribe('task:statusChanged', (event: any) => { - console.log('[Kanban] Task status changed:', event); - // Optimistic update - queryClient.setQueryData(['tasks'], (old: any) => { - if (!old) return old; - return old.map((task: any) => - task.id === event.taskId ? { ...task, status: event.newStatus } : task - ); - }); - }); - - const unsubTaskAssigned = handlers.subscribe('task:assigned', (event: any) => { - console.log('[Kanban] Task assigned:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }); - - const unsubTaskUnassigned = handlers.subscribe('task:unassigned', (event: any) => { - console.log('[Kanban] Task unassigned:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }); - - const unsubTaskCompleted = handlers.subscribe('task:completed', (event: any) => { - console.log('[Kanban] Task completed:', event); - queryClient.invalidateQueries({ queryKey: ['tasks'] }); - }); - - console.log('[Kanban] Subscribed to 19 SignalR events'); - - // Cleanup all subscriptions - return () => { - console.log('[Kanban] Unsubscribing from SignalR events...'); - unsubEpicCreated(); - unsubEpicUpdated(); - unsubEpicDeleted(); - unsubEpicStatusChanged(); - unsubEpicAssigned(); - unsubEpicUnassigned(); - unsubStoryCreated(); - unsubStoryUpdated(); - unsubStoryDeleted(); - unsubStoryStatusChanged(); - unsubStoryAssigned(); - unsubStoryUnassigned(); - unsubTaskCreated(); - unsubTaskUpdated(); - unsubTaskDeleted(); - unsubTaskStatusChanged(); - unsubTaskAssigned(); - unsubTaskUnassigned(); - unsubTaskCompleted(); - }; - }, [isConnected, service, projectId, queryClient]); + // Task events (4 events) + 'TaskCreated': (event: any) => { + console.log('[Kanban] Task created:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }, + 'TaskUpdated': (event: any) => { + console.log('[Kanban] Task updated:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }, + 'TaskDeleted': (event: any) => { + console.log('[Kanban] Task deleted:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }, + 'TaskAssigned': (event: any) => { + console.log('[Kanban] Task assigned:', event); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + }, + }, + [projectId, queryClient] + ); // Combine all work items into unified format const allWorkItems = useMemo(() => { diff --git a/app/layout.tsx b/app/layout.tsx index 2984359..83f9ee0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { QueryProvider } from "@/lib/providers/query-provider"; -import { SignalRProvider } from "@/components/providers/SignalRProvider"; +import { SignalRProvider } from "@/lib/signalr/SignalRContext"; import { Toaster } from "@/components/ui/sonner"; const geistSans = Geist({ diff --git a/lib/signalr/SignalRContext.tsx b/lib/signalr/SignalRContext.tsx new file mode 100644 index 0000000..c5d8704 --- /dev/null +++ b/lib/signalr/SignalRContext.tsx @@ -0,0 +1,288 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react'; +import { HubConnection } from '@microsoft/signalr'; +import { SignalRConnectionManager, ConnectionState } from './ConnectionManager'; +import { SIGNALR_CONFIG } from './config'; +import { useAuthStore } from '@/stores/authStore'; +import { toast } from 'sonner'; + +// ============================================ +// TYPE DEFINITIONS +// ============================================ + +interface SignalRContextValue { + // Connection management + connection: HubConnection | null; + connectionState: ConnectionState; + isConnected: boolean; + + // Event handlers registry + service: SignalREventService | null; + + // Manual connection control (optional) + connect: () => Promise; + disconnect: () => Promise; +} + +// Event subscription service +interface SignalREventService { + subscribe: (eventName: string, handler: (...args: any[]) => void) => () => void; + unsubscribe: (eventName: string, handler?: (...args: any[]) => void) => void; + getEventHandlers: () => SignalREventService | null; +} + +// ============================================ +// CONTEXT CREATION +// ============================================ + +const SignalRContext = createContext(null); + +// ============================================ +// PROVIDER COMPONENT +// ============================================ + +interface SignalRProviderProps { + children: React.ReactNode; + hubUrl?: string; // Optional: custom hub URL (defaults to PROJECT hub) + autoConnect?: boolean; // Auto-connect when authenticated (default: true) + showToasts?: boolean; // Show connection status toasts (default: true) +} + +export function SignalRProvider({ + children, + hubUrl = SIGNALR_CONFIG.HUB_URLS.PROJECT, + autoConnect = true, + showToasts = true, +}: SignalRProviderProps) { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const [connectionState, setConnectionState] = useState('disconnected'); + const managerRef = useRef(null); + const eventHandlersRef = useRef void>>>(new Map()); + + // ============================================ + // EVENT SERVICE IMPLEMENTATION + // ============================================ + + const eventService: SignalREventService = { + subscribe: (eventName: string, handler: (...args: any[]) => void) => { + // Register handler + if (!eventHandlersRef.current.has(eventName)) { + eventHandlersRef.current.set(eventName, new Set()); + } + eventHandlersRef.current.get(eventName)?.add(handler); + + // Subscribe to SignalR event + if (managerRef.current) { + managerRef.current.on(eventName, handler); + } + + // Return unsubscribe function + return () => { + eventHandlersRef.current.get(eventName)?.delete(handler); + if (managerRef.current) { + managerRef.current.off(eventName, handler); + } + }; + }, + + unsubscribe: (eventName: string, handler?: (...args: any[]) => void) => { + if (handler) { + eventHandlersRef.current.get(eventName)?.delete(handler); + if (managerRef.current) { + managerRef.current.off(eventName, handler); + } + } else { + // Unsubscribe all handlers for this event + eventHandlersRef.current.delete(eventName); + if (managerRef.current) { + managerRef.current.off(eventName); + } + } + }, + + getEventHandlers: () => eventService, + }; + + // ============================================ + // CONNECTION MANAGEMENT + // ============================================ + + const connect = useCallback(async () => { + if (!isAuthenticated) { + console.warn('[SignalRContext] Cannot connect: user not authenticated'); + return; + } + + if (managerRef.current?.state === 'connected') { + console.log('[SignalRContext] Already connected'); + return; + } + + const manager = new SignalRConnectionManager(hubUrl); + managerRef.current = manager; + + // Subscribe to state changes + manager.onStateChange((state) => { + setConnectionState(state); + + // Show toast notifications + if (showToasts) { + if (state === 'connected') { + toast.success('Connected to real-time updates'); + } else if (state === 'disconnected') { + toast.error('Disconnected from real-time updates'); + } else if (state === 'reconnecting') { + toast.info('Reconnecting...'); + } + } + }); + + // Re-subscribe all registered event handlers + eventHandlersRef.current.forEach((handlers, eventName) => { + handlers.forEach((handler) => { + manager.on(eventName, handler); + }); + }); + + try { + await manager.start(); + } catch (error) { + console.error('[SignalRContext] Connection error:', error); + if (showToasts) { + toast.error('Failed to connect to real-time updates'); + } + } + }, [isAuthenticated, hubUrl, showToasts]); + + const disconnect = useCallback(async () => { + if (managerRef.current) { + await managerRef.current.stop(); + managerRef.current = null; + } + }, []); + + // ============================================ + // AUTO-CONNECT ON AUTHENTICATION + // ============================================ + + useEffect(() => { + if (autoConnect && isAuthenticated) { + connect(); + } else if (!isAuthenticated) { + disconnect(); + } + + return () => { + disconnect(); + }; + }, [isAuthenticated, autoConnect, connect, disconnect]); + + // ============================================ + // CONTEXT VALUE + // ============================================ + + const contextValue: SignalRContextValue = { + connection: managerRef.current?.['connection'] ?? null, + connectionState, + isConnected: connectionState === 'connected', + service: eventService, + connect, + disconnect, + }; + + return ( + + {children} + + ); +} + +// ============================================ +// CUSTOM HOOKS +// ============================================ + +/** + * Access SignalR context + */ +export function useSignalRContext(): SignalRContextValue { + const context = useContext(SignalRContext); + if (!context) { + throw new Error('useSignalRContext must be used within SignalRProvider'); + } + return context; +} + +/** + * Subscribe to a specific SignalR event (simplified hook) + * + * @example + * useSignalREvent('TaskStatusChanged', (taskId, newStatus) => { + * console.log('Task status changed:', taskId, newStatus); + * }); + */ +export function useSignalREvent( + eventName: string, + handler: (...args: any[]) => void, + deps: React.DependencyList = [] +) { + const { service, isConnected } = useSignalRContext(); + + useEffect(() => { + if (!isConnected || !service) { + return; + } + + const unsubscribe = service.subscribe(eventName, handler); + + return () => { + unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventName, isConnected, service, ...deps]); +} + +/** + * Subscribe to multiple SignalR events + * + * @example + * useSignalREvents({ + * 'TaskCreated': (task) => console.log('Task created:', task), + * 'TaskUpdated': (task) => console.log('Task updated:', task), + * }); + */ +export function useSignalREvents( + events: Record void>, + deps: React.DependencyList = [] +) { + const { service, isConnected } = useSignalRContext(); + + useEffect(() => { + if (!isConnected || !service) { + return; + } + + const unsubscribers = Object.entries(events).map(([eventName, handler]) => + service.subscribe(eventName, handler) + ); + + return () => { + unsubscribers.forEach((unsubscribe) => unsubscribe()); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isConnected, service, ...deps]); +} + +/** + * Get connection state and status + */ +export function useSignalRConnection() { + const { connectionState, isConnected, connect, disconnect } = useSignalRContext(); + + return { + connectionState, + isConnected, + connect, + disconnect, + }; +}