veza/apps/web/src/features/tracks/components/comments/CommentSection.tsx
senke 1006cc7e3a refactor(comments): modularize CommentSection with atomic sub-components
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 07:32:37 +01:00

193 lines
6 KiB
TypeScript

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 { useIsRateLimited } from '@/hooks/useIsRateLimited';
import { Card, CardContent } from '@/components/ui/card';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import type { TrackComment } from '../../services/commentService';
import { CommentSectionHeader } from './CommentSectionHeader';
import { CommentSectionSkeleton } from './CommentSectionSkeleton';
import { CommentSectionEmpty } from './CommentSectionEmpty';
import { CommentSectionError } from './CommentSectionError';
import { CommentEditor } from './CommentEditor';
import { CommentList } from './CommentList';
import { CommentSectionPagination } from './CommentSectionPagination';
export interface CommentSectionProps {
trackId: string;
}
const LIMIT = 20;
export function CommentSection({ trackId }: CommentSectionProps) {
const { data: user } = useUser();
const toast = useToast();
const queryClient = useQueryClient();
const isRateLimited = useIsRateLimited();
const [newComment, setNewComment] = useState('');
const [mutationError, setMutationError] = useState<Error | null>(null);
const [page, setPage] = useState(1);
const [lastCommentContent, setLastCommentContent] = useState('');
const [retryCount, setRetryCount] = useState(0);
const {
data: commentsData,
isLoading,
error,
} = useQuery({
queryKey: ['trackComments', trackId, page],
queryFn: () => getComments(trackId, page, LIMIT),
enabled: !!trackId,
});
const createCommentMutation = useMutation({
mutationFn: (content: string) => createComment(trackId, content),
onMutate: async (content: string) => {
await queryClient.cancelQueries({ queryKey: ['trackComments', trackId] });
const previousComments = queryClient.getQueryData<CommentListResponse>([
'trackComments',
trackId,
page,
]);
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: (err: Error, _content, context) => {
if (context?.previousComments) {
queryClient.setQueryData(
['trackComments', trackId, page],
context.previousComments,
);
}
setMutationError(
new Error(err.message || 'Erreur lors de la publication'),
);
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());
};
const handleRetry = async () => {
if (!lastCommentContent || retryCount >= 3) return;
setRetryCount((prev) => prev + 1);
try {
await createCommentMutation.mutateAsync(lastCommentContent);
} catch {
// Handled by mutation onError
}
};
const topLevelComments =
commentsData?.comments?.filter((c: TrackComment) => !c.parent_id) || [];
const total = commentsData?.total || 0;
const totalPages = Math.ceil(total / LIMIT);
return (
<Card>
<CommentSectionHeader count={commentsData?.total ?? 0} />
<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('');
}}
/>
)}
<CommentEditor
value={newComment}
onChange={setNewComment}
onSubmit={handleSubmit}
isPending={createCommentMutation.isPending}
isRateLimited={!!isRateLimited}
hasUser={!!user}
/>
{isLoading ? (
<CommentSectionSkeleton rows={4} />
) : error ? (
<CommentSectionError
error={
error instanceof Error
? error
: new Error('Failed to load comments')
}
resourceId={trackId}
onRetry={() =>
queryClient.invalidateQueries({
queryKey: ['trackComments', trackId],
})
}
/>
) : topLevelComments.length === 0 ? (
<CommentSectionEmpty />
) : (
<>
<CommentList comments={topLevelComments} trackId={trackId} />
<CommentSectionPagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</>
)}
</CardContent>
</Card>
);
}