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

261 lines
8.2 KiB
TypeScript
Raw Normal View History

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
getComments,
createComment,
type CommentListResponse,
} from '../services/commentService';
import { useUser } from '@/features/auth/hooks/useUser';
import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { MessageCircle, Send, Loader2 } from 'lucide-react';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { CommentThread } from './CommentThread';
import type { TrackComment } from '../services/commentService';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
// FE-PAGE-007: Complete Track Detail page implementation - Comments Section
interface CommentSectionProps {
trackId: string;
}
export function CommentSection({ trackId }: CommentSectionProps) {
const { data: user } = useUser();
const toast = useToast();
const queryClient = useQueryClient();
const [newComment, setNewComment] = useState('');
const [mutationError, setMutationError] = useState<Error | null>(null);
const [page, setPage] = useState(1);
const limit = 20;
const {
data: commentsData,
isLoading,
error,
} = useQuery({
queryKey: ['trackComments', trackId, page],
queryFn: () => getComments(trackId, page, limit),
enabled: !!trackId,
});
// Action 3.4.1.3: Store last comment content for retry
const [lastCommentContent, setLastCommentContent] = useState<string>('');
const [retryCount, setRetryCount] = useState(0);
// Action 4.4.1.5: Add optimistic update
const createCommentMutation = useMutation({
mutationFn: (content: string) => createComment(trackId, content),
onMutate: async (content: string) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] });
// Snapshot previous value
const previousComments = queryClient.getQueryData<CommentListResponse>([
'trackComments',
trackId,
page,
]);
// Optimistically add new comment
if (previousComments && user) {
const optimisticComment: TrackComment = {
id: `temp-${Date.now()}`,
track_id: trackId,
user_id: user.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<CommentListResponse>(
['trackComments', trackId, page],
{
...previousComments,
comments: [optimisticComment, ...previousComments.comments],
total: (previousComments.total || 0) + 1,
},
);
}
return { previousComments };
},
onError: (error: any, _content, context) => {
// Rollback on error
if (context?.previousComments) {
queryClient.setQueryData(
['trackComments', trackId, page],
context.previousComments,
);
}
setMutationError(
new Error(error.message || 'Erreur lors de la publication'),
);
// Store content for retry
setLastCommentContent(newComment.trim());
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trackComments', trackId] });
setNewComment('');
setMutationError(null);
setRetryCount(0);
setLastCommentContent('');
toast.success('Commentaire publié');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim() || !user) return;
setLastCommentContent(newComment.trim());
createCommentMutation.mutate(newComment.trim());
};
// Action 3.4.1.3: Retry handler for failed mutations
const handleRetry = async () => {
if (!lastCommentContent || retryCount >= 3) return;
setRetryCount((prev) => prev + 1);
try {
await createCommentMutation.mutateAsync(lastCommentContent);
} catch (error) {
// Error will be handled by mutation's onError
}
};
// Filter to show only top-level comments (no parent_id)
const topLevelComments =
commentsData?.comments?.filter((c: TrackComment) => !c.parent_id) || [];
const total = commentsData?.total || 0;
const totalPages = Math.ceil(total / limit);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Commentaires ({commentsData?.total || 0})
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{mutationError && (
<ErrorDisplay
error={mutationError}
variant="banner"
severity="error"
context={{
action: 'publishing comment',
resource: 'comment',
}}
onRetry={retryCount < 3 ? handleRetry : undefined}
onDismiss={() => {
setMutationError(null);
setRetryCount(0);
setLastCommentContent('');
}}
/>
)}
{/* Comment Form */}
{user ? (
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Écrire un commentaire..."
maxLength={500}
/>
<Button
type="submit"
disabled={!newComment.trim() || createCommentMutation.isPending}
>
{createCommentMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</form>
) : (
<p className="text-sm text-muted-foreground">
Connectez-vous pour commenter
</p>
)}
{/* Comments List */}
{isLoading ? (
<div className="flex justify-center py-8">
<LoadingSpinner />
</div>
) : error ? (
<ErrorDisplay
error={
error instanceof Error
? error
: new Error('Failed to load comments')
}
variant="card"
severity="error"
context={{
action: 'fetching comments',
resource: 'comments',
resourceId: trackId,
}}
onRetry={() =>
queryClient.invalidateQueries({
queryKey: ['trackComments', trackId],
})
}
/>
) : topLevelComments.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
Aucun commentaire pour le moment. Soyez le premier à commenter !
</div>
) : (
<div className="space-y-4">
{topLevelComments.map((comment: TrackComment) => (
<CommentThread
key={comment.id}
comment={comment}
trackId={trackId}
/>
))}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 pt-4">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Précédent
</Button>
<span className="text-sm text-muted-foreground">
Page {page} sur {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
>
Suivant
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}