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`.
This commit is contained in:
Mathis H (Avnyr) 2025-05-16 16:41:37 +02:00
parent ce7e89d339
commit d7255444f5
5 changed files with 365 additions and 87 deletions

View File

@ -3,6 +3,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { AuthProvider } from "@/lib/auth-context"; import { AuthProvider } from "@/lib/auth-context";
import { SocketProvider } from "@/lib/socket-context";
import { NotificationsListener } from "@/components/notifications";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@ -37,14 +39,17 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<AuthProvider> <AuthProvider>
<ThemeProvider <SocketProvider>
attribute="class" <ThemeProvider
defaultTheme="system" attribute="class"
enableSystem defaultTheme="system"
disableTransitionOnChange enableSystem
> disableTransitionOnChange
{children} >
</ThemeProvider> <NotificationsListener />
{children}
</ThemeProvider>
</SocketProvider>
</AuthProvider> </AuthProvider>
</body> </body>
</html> </html>

View File

@ -14,10 +14,12 @@ import {
Loader2, Loader2,
Wand2, Wand2,
Save, Save,
RefreshCw RefreshCw,
Users
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
import { useSocket } from "@/lib/socket-context";
// Mock project data (same as in the groups page) // Mock project data (same as in the groups page)
const getProjectData = (id: string) => { const getProjectData = (id: string) => {
@ -78,6 +80,9 @@ export default function AutoCreateGroupsPage() {
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Socket connection for real-time updates
const { isConnected, joinProject, leaveProject, onGroupCreated } = useSocket();
// State for auto-generation parameters // State for auto-generation parameters
const [numberOfGroups, setNumberOfGroups] = useState(4); const [numberOfGroups, setNumberOfGroups] = useState(4);
const [balanceTags, setBalanceTags] = useState(true); const [balanceTags, setBalanceTags] = useState(true);
@ -86,6 +91,36 @@ export default function AutoCreateGroupsPage() {
const [availableTags, setAvailableTags] = useState<string[]>([]); const [availableTags, setAvailableTags] = useState<string[]>([]);
const [availableLevels, setAvailableLevels] = useState<string[]>([]); const [availableLevels, setAvailableLevels] = useState<string[]>([]);
// 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(() => { useEffect(() => {
// Fetch project data from API // Fetch project data from API
const fetchProject = async () => { const fetchProject = async () => {
@ -163,6 +198,12 @@ export default function AutoCreateGroupsPage() {
setGenerating(true); setGenerating(true);
try { 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 // Use the API service to generate groups
const { groupsAPI } = await import('@/lib/api'); const { groupsAPI } = await import('@/lib/api');
@ -338,6 +379,12 @@ export default function AutoCreateGroupsPage() {
</Link> </Link>
</Button> </Button>
<h1 className="text-3xl font-bold">Assistant de création de groupes</h1> <h1 className="text-3xl font-bold">Assistant de création de groupes</h1>
{isConnected && (
<div className="flex items-center gap-2 ml-4 text-sm text-muted-foreground">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span>Collaboration en temps réel active</span>
</div>
)}
</div> </div>
<Button onClick={handleSaveGroups} disabled={saving || groups.length === 0}> <Button onClick={handleSaveGroups} disabled={saving || groups.length === 0}>
{saving ? ( {saving ? (
@ -470,6 +517,12 @@ export default function AutoCreateGroupsPage() {
<p className="text-center text-muted-foreground"> <p className="text-center text-muted-foreground">
Aucun groupe généré. Cliquez sur "Générer les groupes" pour commencer. Aucun groupe généré. Cliquez sur "Générer les groupes" pour commencer.
</p> </p>
{isConnected && (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<span>Collaboration en temps réel active</span>
</div>
)}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -11,10 +11,12 @@ import {
Users, Users,
Wand2, Wand2,
ArrowLeft, ArrowLeft,
Loader2 Loader2,
RefreshCw
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
import { useSocket } from "@/lib/socket-context";
// Mock project data // Mock project data
const getProjectData = (id: string) => { const getProjectData = (id: string) => {
@ -88,39 +90,187 @@ const getProjectData = (id: string) => {
export default function ProjectGroupsPage() { export default function ProjectGroupsPage() {
const params = useParams(); const params = useParams();
const router = useRouter();
const projectId = params.id as string; const projectId = params.id as string;
const [project, setProject] = useState<any>(null); const [project, setProject] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [activeTab, setActiveTab] = useState("existing"); const [activeTab, setActiveTab] = useState("existing");
useEffect(() => { // Socket connection for real-time updates
// Simulate API call to fetch project data const { isConnected, joinProject, leaveProject, onGroupCreated, onGroupUpdated, onPersonAddedToGroup, onPersonRemovedFromGroup } = useSocket();
const fetchProject = async () => {
setLoading(true);
try {
// In a real app, this would be an API call
await new Promise(resolve => setTimeout(resolve, 1000));
const data = getProjectData(projectId);
setProject(data);
} catch (error) {
console.error("Error fetching project:", error);
toast.error("Erreur lors du chargement du projet");
} finally {
setLoading(false);
}
};
// Fetch project data from API
const fetchProject = async () => {
setLoading(true);
try {
// Use the API service to get project and groups data
const { projectsAPI, groupsAPI } = await import('@/lib/api');
const projectData = await projectsAPI.getProject(projectId);
const groupsData = await groupsAPI.getGroups(projectId);
// Combine project data with groups data
const data = {
...projectData,
groups: groupsData || []
};
setProject(data);
} catch (error) {
console.error("Error fetching project:", error);
toast.error("Erreur lors du chargement du projet");
// Fallback to mock data for development
const data = getProjectData(projectId);
setProject(data);
} finally {
setLoading(false);
setRefreshing(false);
}
};
// Initial fetch
useEffect(() => {
fetchProject(); fetchProject();
}, [projectId]); }, [projectId]);
const handleCreateGroups = async () => { // Join project room for real-time updates when connected
toast.success("Redirection vers la page de création de groupes"); useEffect(() => {
// In a real app, this would redirect to the group creation page 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) return;
const unsubscribe = onGroupCreated((data) => {
console.log("Group created:", data);
if (data.action === "created" && data.group) {
// Add the new group to the list
setProject(prev => ({
...prev,
groups: [...prev.groups, data.group]
}));
toast.success(`Nouveau groupe créé: ${data.group.name}`);
}
});
return () => {
unsubscribe();
};
}, [isConnected, onGroupCreated]);
// Listen for group updated events
useEffect(() => {
if (!isConnected) return;
const unsubscribe = onGroupUpdated((data) => {
console.log("Group updated:", data);
if (data.action === "updated" && data.group) {
// Update the group in the list
setProject(prev => ({
...prev,
groups: prev.groups.map(group =>
group.id === data.group.id ? data.group : group
)
}));
toast.info(`Groupe mis à jour: ${data.group.name}`);
} else if (data.action === "deleted" && data.group) {
// Remove the group from the list
setProject(prev => ({
...prev,
groups: prev.groups.filter(group => group.id !== data.group.id)
}));
toast.info(`Groupe supprimé: ${data.group.name}`);
}
});
return () => {
unsubscribe();
};
}, [isConnected, onGroupUpdated]);
// Listen for person added to group events
useEffect(() => {
if (!isConnected) return;
const unsubscribe = onPersonAddedToGroup((data) => {
console.log("Person added to group:", data);
if (data.group && data.person) {
// Update the group with the new person
setProject(prev => ({
...prev,
groups: prev.groups.map(group => {
if (group.id === data.group.id) {
return {
...group,
persons: [...group.persons, data.person]
};
}
return group;
})
}));
toast.success(`${data.person.name} a été ajouté au groupe ${data.group.name}`);
}
});
return () => {
unsubscribe();
};
}, [isConnected, onPersonAddedToGroup]);
// Listen for person removed from group events
useEffect(() => {
if (!isConnected) return;
const unsubscribe = onPersonRemovedFromGroup((data) => {
console.log("Person removed from group:", data);
if (data.group && data.person) {
// Update the group by removing the person
setProject(prev => ({
...prev,
groups: prev.groups.map(group => {
if (group.id === data.group.id) {
return {
...group,
persons: group.persons.filter(person => person.id !== data.person.id)
};
}
return group;
})
}));
toast.info(`${data.person.name} a été retiré du groupe ${data.group.name}`);
}
});
return () => {
unsubscribe();
};
}, [isConnected, onPersonRemovedFromGroup]);
const handleCreateGroups = () => {
router.push(`/projects/${projectId}/groups/create`);
}; };
const handleAutoCreateGroups = async () => { const handleAutoCreateGroups = () => {
toast.success("Redirection vers l'assistant de création automatique de groupes"); router.push(`/projects/${projectId}/groups/auto-create`);
// In a real app, this would redirect to the automatic group creation page
}; };
if (loading) { if (loading) {
@ -144,13 +294,27 @@ export default function ProjectGroupsPage() {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<Button variant="outline" size="icon" asChild> <div className="flex items-center gap-2">
<Link href={`/projects/${projectId}`}> <Button variant="outline" size="icon" asChild>
<ArrowLeft className="h-4 w-4" /> <Link href={`/projects/${projectId}`}>
</Link> <ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">{project.name} - Groupes</h1>
</div>
<Button
variant="outline"
size="icon"
onClick={() => {
setRefreshing(true);
fetchProject();
}}
disabled={loading || refreshing}
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
<span className="sr-only">Rafraîchir</span>
</Button> </Button>
<h1 className="text-3xl font-bold">{project.name} - Groupes</h1>
</div> </div>
<Tabs defaultValue="existing" className="space-y-4" onValueChange={setActiveTab}> <Tabs defaultValue="existing" className="space-y-4" onValueChange={setActiveTab}>

View File

@ -35,8 +35,11 @@ import {
Pencil, Pencil,
Trash2, Trash2,
Users, Users,
Eye Eye,
RefreshCw
} from "lucide-react"; } from "lucide-react";
import { useSocket } from "@/lib/socket-context";
import { toast } from "sonner";
// Define the Project type // Define the Project type
interface Project { interface Project {
@ -55,55 +58,95 @@ export default function ProjectsPage() {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
// Socket connection for real-time updates
const { isConnected, onProjectUpdated } = useSocket();
// Fetch projects from API // Fetch projects from API
useEffect(() => { const fetchProjects = async () => {
const fetchProjects = async () => { setIsLoading(true);
setIsLoading(true); try {
try { const data = await import('@/lib/api').then(module =>
const data = await import('@/lib/api').then(module => module.projectsAPI.getProjects()
module.projectsAPI.getProjects() );
); setProjects(data);
setProjects(data); setError(null);
setError(null); } catch (err) {
} catch (err) { console.error("Failed to fetch projects:", err);
console.error("Failed to fetch projects:", err); setError("Impossible de charger les projets. Veuillez réessayer plus tard.");
setError("Impossible de charger les projets. Veuillez réessayer plus tard."); // Fallback to mock data for development
// Fallback to mock data for development setProjects([
setProjects([ {
{ id: 1,
id: 1, name: "Projet Formation Dev Web",
name: "Projet Formation Dev Web", description: "Création de groupes pour la formation développement web",
description: "Création de groupes pour la formation développement web", date: "2025-05-15",
date: "2025-05-15", groups: 4,
groups: 4, persons: 16,
persons: 16, },
}, {
{ id: 2,
id: 2, name: "Projet Hackathon",
name: "Projet Hackathon", description: "Équipes pour le hackathon annuel",
description: "Équipes pour le hackathon annuel", date: "2025-05-10",
date: "2025-05-10", groups: 8,
groups: 8, persons: 32,
persons: 32, },
}, {
{ id: 3,
id: 3, name: "Projet Workshop UX/UI",
name: "Projet Workshop UX/UI", description: "Groupes pour l'atelier UX/UI",
description: "Groupes pour l'atelier UX/UI", date: "2025-05-05",
date: "2025-05-05", groups: 5,
groups: 5, persons: 20,
persons: 20, },
}, ]);
]); } finally {
} finally { setIsLoading(false);
setIsLoading(false); setRefreshing(false);
} }
}; };
// Initial fetch
useEffect(() => {
fetchProjects(); 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 // Filter projects based on search query
const filteredProjects = projects.filter( const filteredProjects = projects.filter(
(project) => (project) =>
@ -135,6 +178,18 @@ export default function ProjectsPage() {
disabled={isLoading} disabled={isLoading}
/> />
</div> </div>
<Button
variant="outline"
size="icon"
onClick={() => {
setRefreshing(true);
fetchProjects();
}}
disabled={isLoading || refreshing}
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
<span className="sr-only">Rafraîchir</span>
</Button>
</div> </div>
{error && ( {error && (

View File

@ -52,6 +52,7 @@
"react-resizable-panels": "^3.0.2", "react-resizable-panels": "^3.0.2",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"sonner": "^2.0.3", "sonner": "^2.0.3",
"socket.io-client": "^4.8.1",
"swr": "^2.3.3", "swr": "^2.3.3",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",