veza/apps/web/src/features/tracks/components/CommentThread.tsx
senke d18dd2cca4 fix(storybook): remediate crashes and improve mock stability
- Add global AuthProvider and QueryClientProvider
- Fix Loader2 reference error in CommentThread
- Fix coverUrl crash in ProductCard
- Fix double-slash URL bug in logger
- Improve MSW handlers and environment config
2026-02-04 19:33:00 +01:00

547 lines
18 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 } 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 { Spinner } from '@/components/ui/Spinner';
import { useUser } from '@/features/auth/hooks/useUser';
import { useToast } from '@/hooks/useToast';
import {
createComment,
updateComment,
deleteComment,
getReplies,
type TrackComment,
type CommentListResponse,
type ReplyListResponse,
} 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 { data: user } = useUser();
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
// Action 4.4.1.5: Add optimistic update
const createReplyMutation = useMutation({
mutationFn: (content: string) =>
createComment(trackId, content, comment.id),
onMutate: async (content: string) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: ['commentReplies', comment.id],
});
await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] });
// Snapshot previous values
const previousReplies = queryClient.getQueryData<ReplyListResponse>([
'commentReplies',
comment.id,
]);
const previousComments = queryClient.getQueryData<CommentListResponse>([
'trackComments',
trackId,
]);
// Optimistically add reply
if (previousReplies && user) {
const optimisticReply: TrackComment = {
id: `temp-${Date.now()}`,
track_id: trackId,
user_id: user.id,
parent_id: comment.id,
content: content.trim(),
is_edited: false,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
user: {
id: user.id,
username: user.username || '',
avatar: user.avatar_url,
},
};
queryClient.setQueryData<ReplyListResponse>(
['commentReplies', comment.id],
{
...previousReplies,
replies: [...(previousReplies.replies || []), optimisticReply],
},
);
}
return { previousReplies, previousComments };
},
onError: (error: any, _content, context) => {
// Rollback on error
if (context?.previousReplies) {
queryClient.setQueryData(
['commentReplies', comment.id],
context.previousReplies,
);
}
if (context?.previousComments) {
queryClient.setQueryData(
['trackComments', trackId],
context.previousComments,
);
}
showError(error.message || 'Erreur lors de la publication de la réponse');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] });
queryClient.invalidateQueries({
queryKey: ['commentReplies', comment.id],
});
setReplyContent('');
setIsReplying(false);
setShowReplies(true);
showSuccess('Réponse publiée');
},
});
// Update comment mutation
// Action 4.4.1.5: Add optimistic update
const updateCommentMutation = useMutation({
mutationFn: (content: string) => updateComment(comment.id, content),
onMutate: async (content: string) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] });
await queryClient.cancelQueries({
queryKey: ['commentReplies', comment.parent_id || comment.id],
});
// Snapshot previous values
const previousComments = queryClient.getQueryData<CommentListResponse>([
'trackComments',
trackId,
]);
const previousReplies = comment.parent_id
? queryClient.getQueryData<ReplyListResponse>([
'commentReplies',
comment.parent_id,
])
: null;
// Optimistically update comment in comments list
if (previousComments) {
queryClient.setQueryData<CommentListResponse>(
['trackComments', trackId],
{
...previousComments,
comments: previousComments.comments.map((c) =>
c.id === comment.id
? {
...c,
content: content.trim(),
is_edited: true,
updated_at: new Date().toISOString(),
}
: c,
),
},
);
}
// Optimistically update reply in replies list
if (previousReplies && comment.parent_id) {
queryClient.setQueryData<ReplyListResponse>(
['commentReplies', comment.parent_id],
{
...previousReplies,
replies: previousReplies.replies.map((r) =>
r.id === comment.id
? {
...r,
content: content.trim(),
is_edited: true,
updated_at: new Date().toISOString(),
}
: r,
),
},
);
}
return { previousComments, previousReplies };
},
onError: (error: any, _content, context) => {
// Rollback on error
if (context?.previousComments) {
queryClient.setQueryData(
['trackComments', trackId],
context.previousComments,
);
}
if (context?.previousReplies && comment.parent_id) {
queryClient.setQueryData(
['commentReplies', comment.parent_id],
context.previousReplies,
);
}
showError(error.message || 'Erreur lors de la modification');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] });
setIsEditing(false);
showSuccess('Commentaire modifié');
},
});
// Delete comment mutation
// Action 4.4.1.5: Add optimistic update
const deleteCommentMutation = useMutation({
mutationFn: () => deleteComment(comment.id),
onMutate: async () => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] });
await queryClient.cancelQueries({
queryKey: ['commentReplies', comment.parent_id || comment.id],
});
// Snapshot previous values
const previousComments = queryClient.getQueryData<CommentListResponse>([
'trackComments',
trackId,
]);
const previousReplies = comment.parent_id
? queryClient.getQueryData<ReplyListResponse>([
'commentReplies',
comment.parent_id,
])
: null;
// Optimistically remove comment from comments list
if (previousComments) {
queryClient.setQueryData<CommentListResponse>(
['trackComments', trackId],
{
...previousComments,
comments: previousComments.comments.filter(
(c) => c.id !== comment.id,
),
total: Math.max((previousComments.total || 1) - 1, 0),
},
);
}
// Optimistically remove reply from replies list
if (previousReplies && comment.parent_id) {
queryClient.setQueryData<ReplyListResponse>(
['commentReplies', comment.parent_id],
{
...previousReplies,
replies: previousReplies.replies.filter((r) => r.id !== comment.id),
},
);
}
return { previousComments, previousReplies };
},
onError: (_error, _variables, context) => {
// Rollback on error
if (context?.previousComments) {
queryClient.setQueryData(
['trackComments', trackId],
context.previousComments,
);
}
if (context?.previousReplies && comment.parent_id) {
queryClient.setQueryData(
['commentReplies', comment.parent_id],
context.previousReplies,
);
}
showError('Erreur lors de la suppression');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] });
setShowDeleteDialog(false);
showSuccess('Commentaire supprimé');
},
});
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-4', className)}>
{/* Main Comment */}
<div className="flex gap-4">
<Avatar
src={comment.user?.avatar}
fallback={comment.user?.username?.charAt(0).toUpperCase() || 'U'}
size="sm"
className="h-8 w-8 shrink-0"
/>
<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 ? (
<Spinner size="sm" className="mr-2" />
) : (
<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 ? (
<Spinner size="sm" className="mr-2" />
) : (
<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-4 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"
isLoading={deleteCommentMutation.isPending}
/>
</>
);
}