veza/apps/web/src/features/tracks/components/CommentThread.tsx

349 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
import {
MessageCircle,
Reply,
Trash2,
Edit2,
MoreVertical,
Send,
X,
Loader2,
} from 'lucide-react';
import { useAuthStore } from '@/features/auth/store/authStore';
import { useToast } from '@/hooks/useToast';
import {
createComment,
updateComment,
deleteComment,
getReplies,
type TrackComment,
} from '../services/commentService';
import { cn } from '@/lib/utils';
/**
* FE-COMP-012: Comment thread component with replies and moderation
*/
interface CommentThreadProps {
comment: TrackComment;
trackId: string;
depth?: number;
className?: string;
}
const MAX_DEPTH = 3; // Maximum nesting depth for replies
export function CommentThread({
comment,
trackId,
depth = 0,
className,
}: CommentThreadProps) {
const { user } = useAuthStore();
const { success: showSuccess, error: showError } = useToast();
const queryClient = useQueryClient();
const [isReplying, setIsReplying] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [replyContent, setReplyContent] = useState('');
const [editContent, setEditContent] = useState(comment.content);
const [showReplies, setShowReplies] = useState(depth === 0);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Fetch replies
const {
data: repliesData,
isLoading: isLoadingReplies,
} = useQuery({
queryKey: ['commentReplies', comment.id],
queryFn: () => getReplies(comment.id, 1, 20),
enabled: showReplies && !comment.replies,
});
const replies = comment.replies || repliesData?.replies || [];
const canReply = depth < MAX_DEPTH;
const canEdit = user?.id === comment.user_id;
const canDelete = user?.id === comment.user_id || user?.role === 'admin';
// Create reply mutation
const createReplyMutation = useMutation({
mutationFn: (content: string) => createComment(trackId, content, comment.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] });
queryClient.invalidateQueries({ queryKey: ['commentReplies', comment.id] });
setReplyContent('');
setIsReplying(false);
setShowReplies(true);
showSuccess('Réponse publiée');
},
onError: (error: any) => {
showError(error.message || 'Erreur lors de la publication de la réponse');
},
});
// Update comment mutation
const updateCommentMutation = useMutation({
mutationFn: (content: string) => updateComment(comment.id, content),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] });
setIsEditing(false);
showSuccess('Commentaire modifié');
},
onError: (error: any) => {
showError(error.message || 'Erreur lors de la modification');
},
});
// Delete comment mutation
const deleteCommentMutation = useMutation({
mutationFn: () => deleteComment(comment.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] });
setShowDeleteDialog(false);
showSuccess('Commentaire supprimé');
},
onError: (error: any) => {
showError(error.message || 'Erreur lors de la suppression');
},
});
const handleReplySubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!replyContent.trim() || !user) return;
createReplyMutation.mutate(replyContent.trim());
};
const handleEditSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!editContent.trim()) return;
updateCommentMutation.mutate(editContent.trim());
};
const handleDelete = () => {
deleteCommentMutation.mutate();
};
return (
<>
<div className={cn('space-y-3', className)}>
{/* Main Comment */}
<div className="flex gap-3">
<Avatar className="h-8 w-8 shrink-0">
<AvatarImage src={comment.user?.avatar} />
<AvatarFallback>
{comment.user?.username?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-2">
{/* Comment Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">
{comment.user?.username || 'Utilisateur'}
</span>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(comment.created_at), {
addSuffix: true,
locale: fr,
})}
</span>
{comment.is_edited && (
<span className="text-xs text-muted-foreground italic">
(modifié)
</span>
)}
</div>
</div>
{(canEdit || canDelete) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && (
<DropdownMenuItem onClick={() => setIsEditing(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Modifier
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem
onClick={() => setShowDeleteDialog(true)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Supprimer
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{/* Comment Content */}
{isEditing ? (
<form onSubmit={handleEditSubmit} className="space-y-2">
<Input
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
maxLength={500}
autoFocus
/>
<div className="flex gap-2">
<Button
type="submit"
size="sm"
disabled={
!editContent.trim() || updateCommentMutation.isPending
}
>
{updateCommentMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Send className="h-4 w-4 mr-2" />
)}
Enregistrer
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsEditing(false);
setEditContent(comment.content);
}}
>
<X className="h-4 w-4 mr-2" />
Annuler
</Button>
</div>
</form>
) : (
<p className="text-sm whitespace-pre-wrap break-words">
{comment.content}
</p>
)}
{/* Comment Actions */}
{!isEditing && (
<div className="flex items-center gap-4">
{canReply && user && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsReplying(!isReplying)}
className="h-7 text-xs"
>
<Reply className="h-3 w-3 mr-1" />
Répondre
</Button>
)}
{replies.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowReplies(!showReplies)}
className="h-7 text-xs"
>
<MessageCircle className="h-3 w-3 mr-1" />
{showReplies ? 'Masquer' : 'Afficher'} {replies.length}{' '}
{replies.length === 1 ? 'réponse' : 'réponses'}
</Button>
)}
</div>
)}
{/* Reply Form */}
{isReplying && user && (
<form onSubmit={handleReplySubmit} className="space-y-2 pt-2">
<Input
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder={`Répondre à ${comment.user?.username}...`}
maxLength={500}
autoFocus
/>
<div className="flex gap-2">
<Button
type="submit"
size="sm"
disabled={
!replyContent.trim() || createReplyMutation.isPending
}
>
{createReplyMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Send className="h-4 w-4 mr-2" />
)}
Publier
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsReplying(false);
setReplyContent('');
}}
>
<X className="h-4 w-4 mr-2" />
Annuler
</Button>
</div>
</form>
)}
{/* Replies */}
{showReplies && (
<div className="space-y-3 pt-2 pl-4 border-l-2 border-muted">
{isLoadingReplies ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : replies.length > 0 ? (
replies.map((reply) => (
<CommentThread
key={reply.id}
comment={reply}
trackId={trackId}
depth={depth + 1}
/>
))
) : null}
</div>
)}
</div>
</div>
</div>
{/* Delete Confirmation Dialog */}
<ConfirmationDialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleDelete}
title="Supprimer le commentaire"
description="Êtes-vous sûr de vouloir supprimer ce commentaire ? Cette action est irréversible."
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="destructive"
/>
</>
);
}