feat: implement messaging functionality with real-time updates

- Introduced a messaging module on the backend using NestJS, including repository, service, controller, DTOs, and WebSocket Gateway.
- Developed a frontend messaging page with conversation management, real-time message handling, and chat UI.
- Implemented `MessageService` for API integrations and `SocketProvider` for real-time WebSocket updates.
- Enhanced database schema to support conversations, participants, and messages with Drizzle ORM.
This commit is contained in:
Mathis HERRIOT
2026-01-29 14:34:22 +01:00
parent 01117aad6d
commit fafdaee668
13 changed files with 995 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale";
import { MoreHorizontal, Send, Trash2 } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/providers/auth-provider";
import { CommentService, type Comment } from "@/services/comment.service";
interface CommentSectionProps {
contentId: string;
}
export function CommentSection({ contentId }: CommentSectionProps) {
const { user, isAuthenticated } = useAuth();
const [comments, setComments] = React.useState<Comment[]>([]);
const [newComment, setNewComment] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [isLoading, setIsLoading] = React.useState(true);
const fetchComments = React.useCallback(async () => {
try {
const data = await CommentService.getByContentId(contentId);
setComments(data);
} catch (_error) {
toast.error("Impossible de charger les commentaires");
} finally {
setIsLoading(false);
}
}, [contentId]);
React.useEffect(() => {
fetchComments();
}, [fetchComments]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || isSubmitting) return;
setIsSubmitting(true);
try {
const comment = await CommentService.create(contentId, newComment.trim());
setComments((prev) => [comment, ...prev]);
setNewComment("");
toast.success("Commentaire publié !");
} catch (_error) {
toast.error("Erreur lors de la publication du commentaire");
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (commentId: string) => {
try {
await CommentService.remove(commentId);
setComments((prev) => prev.filter((c) => c.id !== commentId));
toast.success("Commentaire supprimé");
} catch (_error) {
toast.error("Erreur lors de la suppression");
}
};
return (
<div className="space-y-6 mt-8">
<h3 className="font-bold text-lg">Commentaires ({comments.length})</h3>
{isAuthenticated ? (
<form onSubmit={handleSubmit} className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user?.avatarUrl} />
<AvatarFallback>{user?.username[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-2">
<Textarea
placeholder="Ajouter un commentaire..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
className="min-h-[80px] resize-none"
/>
<div className="flex justify-end">
<Button type="submit" size="sm" disabled={!newComment.trim() || isSubmitting}>
{isSubmitting ? "Envoi..." : "Publier"}
<Send className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
</form>
) : (
<div className="bg-zinc-100 dark:bg-zinc-800 p-4 rounded-xl text-center text-sm">
Connectez-vous pour laisser un commentaire.
</div>
)}
<div className="space-y-4">
{isLoading ? (
<div className="text-center text-muted-foreground py-4">Chargement...</div>
) : comments.length === 0 ? (
<div className="text-center text-muted-foreground py-4">
Aucun commentaire pour le moment. Soyez le premier !
</div>
) : (
comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={comment.user.avatarUrl} />
<AvatarFallback>
{comment.user.username[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">
{comment.user.displayName || comment.user.username}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.createdAt), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
{(user?.uuid === comment.user.uuid || user?.role === "admin" || user?.role === "moderator") && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleDelete(comment.id)}
className="text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{comment.text}
</p>
</div>
</div>
))
)}
</div>
</div>
);
}