From d7255444f56b1391b8f298c378008636839548dd Mon Sep 17 00:00:00 2001 From: Avnyr Date: Fri, 16 May 2025 16:41:37 +0200 Subject: [PATCH] feat: implement real-time collaboration and instant updates with socket integration - Added `SocketProvider` for application-wide WebSocket connection management. - Introduced real-time updates for projects and groups, including create, update, and delete events. - Enhanced project and group pages with real-time collaboration, group actions, and data syncing. - Refactored fetch methods to include loading and refreshing states. - Integrated `toast` notifications for real-time event feedback. - Updated `package.json` to include `socket.io-client@4.8.1`. --- frontend/app/layout.tsx | 21 +- .../projects/[id]/groups/auto-create/page.tsx | 55 ++++- frontend/app/projects/[id]/groups/page.tsx | 232 +++++++++++++++--- frontend/app/projects/page.tsx | 143 +++++++---- frontend/package.json | 1 + 5 files changed, 365 insertions(+), 87 deletions(-) diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 963f46c..1dd0c7f 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { AuthProvider } from "@/lib/auth-context"; +import { SocketProvider } from "@/lib/socket-context"; +import { NotificationsListener } from "@/components/notifications"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -37,14 +39,17 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > - - {children} - + + + + {children} + + diff --git a/frontend/app/projects/[id]/groups/auto-create/page.tsx b/frontend/app/projects/[id]/groups/auto-create/page.tsx index abc47a0..a7cf253 100644 --- a/frontend/app/projects/[id]/groups/auto-create/page.tsx +++ b/frontend/app/projects/[id]/groups/auto-create/page.tsx @@ -14,10 +14,12 @@ import { Loader2, Wand2, Save, - RefreshCw + RefreshCw, + Users } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; +import { useSocket } from "@/lib/socket-context"; // Mock project data (same as in the groups page) const getProjectData = (id: string) => { @@ -78,6 +80,9 @@ export default function AutoCreateGroupsPage() { const [generating, setGenerating] = useState(false); const [saving, setSaving] = useState(false); + // Socket connection for real-time updates + const { isConnected, joinProject, leaveProject, onGroupCreated } = useSocket(); + // State for auto-generation parameters const [numberOfGroups, setNumberOfGroups] = useState(4); const [balanceTags, setBalanceTags] = useState(true); @@ -86,6 +91,36 @@ export default function AutoCreateGroupsPage() { const [availableTags, setAvailableTags] = useState([]); const [availableLevels, setAvailableLevels] = useState([]); + // Join project room for real-time updates when connected + useEffect(() => { + if (!isConnected) return; + + // Join the project room to receive updates + joinProject(projectId); + + // Clean up when component unmounts + return () => { + leaveProject(projectId); + }; + }, [isConnected, joinProject, leaveProject, projectId]); + + // Listen for group created events + useEffect(() => { + if (!isConnected || groups.length === 0) return; + + const unsubscribe = onGroupCreated((data) => { + console.log("Group created:", data); + + if (data.action === "created" && data.group) { + toast.info(`Nouveau groupe créé par un collaborateur: ${data.group.name}`); + } + }); + + return () => { + unsubscribe(); + }; + }, [isConnected, onGroupCreated, groups]); + useEffect(() => { // Fetch project data from API const fetchProject = async () => { @@ -163,6 +198,12 @@ export default function AutoCreateGroupsPage() { setGenerating(true); try { + // Notify users that groups are being generated + if (isConnected) { + toast.info("Génération de groupes en cours...", { + description: "Les autres utilisateurs seront notifiés lorsque les groupes seront générés." + }); + } // Use the API service to generate groups const { groupsAPI } = await import('@/lib/api'); @@ -338,6 +379,12 @@ export default function AutoCreateGroupsPage() {

Assistant de création de groupes

+ {isConnected && ( +
+
+ Collaboration en temps réel active +
+ )} +

{project.name} - Groupes

+ + -

{project.name} - Groupes

@@ -158,7 +322,7 @@ export default function ProjectGroupsPage() { Groupes existants Créer des groupes - + {project.groups.length === 0 ? ( @@ -208,7 +372,7 @@ export default function ProjectGroupsPage() { )} - +
@@ -229,7 +393,7 @@ export default function ProjectGroupsPage() { - + Création automatique @@ -253,4 +417,4 @@ export default function ProjectGroupsPage() {
); -} \ No newline at end of file +} diff --git a/frontend/app/projects/page.tsx b/frontend/app/projects/page.tsx index 3064e51..9de237e 100644 --- a/frontend/app/projects/page.tsx +++ b/frontend/app/projects/page.tsx @@ -35,8 +35,11 @@ import { Pencil, Trash2, Users, - Eye + Eye, + RefreshCw } from "lucide-react"; +import { useSocket } from "@/lib/socket-context"; +import { toast } from "sonner"; // Define the Project type interface Project { @@ -55,55 +58,95 @@ export default function ProjectsPage() { const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + // Socket connection for real-time updates + const { isConnected, onProjectUpdated } = useSocket(); // Fetch projects from API - useEffect(() => { - const fetchProjects = async () => { - setIsLoading(true); - try { - const data = await import('@/lib/api').then(module => - module.projectsAPI.getProjects() - ); - setProjects(data); - setError(null); - } catch (err) { - console.error("Failed to fetch projects:", err); - setError("Impossible de charger les projets. Veuillez réessayer plus tard."); - // Fallback to mock data for development - setProjects([ - { - id: 1, - name: "Projet Formation Dev Web", - description: "Création de groupes pour la formation développement web", - date: "2025-05-15", - groups: 4, - persons: 16, - }, - { - id: 2, - name: "Projet Hackathon", - description: "Équipes pour le hackathon annuel", - date: "2025-05-10", - groups: 8, - persons: 32, - }, - { - id: 3, - name: "Projet Workshop UX/UI", - description: "Groupes pour l'atelier UX/UI", - date: "2025-05-05", - groups: 5, - persons: 20, - }, - ]); - } finally { - setIsLoading(false); - } - }; + const fetchProjects = async () => { + setIsLoading(true); + try { + const data = await import('@/lib/api').then(module => + module.projectsAPI.getProjects() + ); + setProjects(data); + setError(null); + } catch (err) { + console.error("Failed to fetch projects:", err); + setError("Impossible de charger les projets. Veuillez réessayer plus tard."); + // Fallback to mock data for development + setProjects([ + { + id: 1, + name: "Projet Formation Dev Web", + description: "Création de groupes pour la formation développement web", + date: "2025-05-15", + groups: 4, + persons: 16, + }, + { + id: 2, + name: "Projet Hackathon", + description: "Équipes pour le hackathon annuel", + date: "2025-05-10", + groups: 8, + persons: 32, + }, + { + id: 3, + name: "Projet Workshop UX/UI", + description: "Groupes pour l'atelier UX/UI", + date: "2025-05-05", + groups: 5, + persons: 20, + }, + ]); + } finally { + setIsLoading(false); + setRefreshing(false); + } + }; + // Initial fetch + useEffect(() => { fetchProjects(); }, []); + // Set up real-time updates for projects + useEffect(() => { + if (!isConnected) return; + + // Listen for project updates + const unsubscribe = onProjectUpdated((data) => { + console.log("Project updated:", data); + + if (data.action === "created") { + // Add the new project to the list + setProjects(prev => [data.project, ...prev]); + toast.success(`Nouveau projet créé: ${data.project.name}`); + } else if (data.action === "updated") { + // Update the project in the list + setProjects(prev => + prev.map(project => + project.id === data.project.id ? data.project : project + ) + ); + toast.info(`Projet mis à jour: ${data.project.name}`); + } else if (data.action === "deleted") { + // Remove the project from the list + setProjects(prev => + prev.filter(project => project.id !== data.project.id) + ); + toast.info(`Projet supprimé: ${data.project.name}`); + } + }); + + return () => { + unsubscribe(); + }; + }, [isConnected, onProjectUpdated]); + // Filter projects based on search query const filteredProjects = projects.filter( (project) => @@ -135,6 +178,18 @@ export default function ProjectsPage() { disabled={isLoading} /> + {error && ( diff --git a/frontend/package.json b/frontend/package.json index 8443875..c65c486 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -52,6 +52,7 @@ "react-resizable-panels": "^3.0.2", "recharts": "^2.15.3", "sonner": "^2.0.3", + "socket.io-client": "^4.8.1", "swr": "^2.3.3", "tailwind-merge": "^3.3.0", "vaul": "^1.1.2",