refactor(comments): modularize CommentSection with atomic sub-components

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-07 07:32:37 +01:00
parent 17b57dc885
commit 65e8a69db2
10 changed files with 464 additions and 267 deletions

View file

@ -1,267 +1,5 @@
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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { MessageCircle, Send } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
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 isRateLimited = useIsRateLimited();
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 ||
isRateLimited
}
>
{createCommentMutation.isPending ? (
<Spinner size="sm" />
) : (
<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>
);
}
/**
* Re-export from comments module.
* FE-PAGE-007: Comment section implementation in ./comments
*/
export { CommentSection } from './comments';

View file

@ -0,0 +1,59 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Send } from 'lucide-react';
import { Spinner } from '@/components/ui/Spinner';
import { cn } from '@/lib/utils';
export interface CommentEditorProps {
value: string;
onChange: (value: string) => void;
onSubmit: (e: React.FormEvent) => void;
placeholder?: string;
maxLength?: number;
isPending?: boolean;
isRateLimited?: boolean;
hasUser: boolean;
className?: string;
}
export function CommentEditor({
value,
onChange,
onSubmit,
placeholder = 'Écrire un commentaire...',
maxLength = 500,
isPending = false,
isRateLimited = false,
hasUser,
className,
}: CommentEditorProps) {
const disabled =
!value.trim() || isPending || isRateLimited;
const showForm = hasUser;
return (
<div className={cn(className)} data-testid="comment-editor">
{showForm ? (
<form onSubmit={onSubmit} className="flex gap-2">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
/>
<Button type="submit" disabled={disabled}>
{isPending ? (
<Spinner size="sm" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</form>
) : (
<p className="text-sm text-muted-foreground">
Connectez-vous pour commenter
</p>
)}
</div>
);
}

View file

@ -0,0 +1,50 @@
import { motion } from 'framer-motion';
import { CommentThread } from '../comment-thread';
import type { TrackComment } from '../../services/commentService';
import { cn } from '@/lib/utils';
const listVariants = {
visible: {
transition: {
staggerChildren: 0.05,
delayChildren: 0.02,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 8 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.25, ease: 'easeOut' },
},
};
export interface CommentListProps {
comments: TrackComment[];
trackId: string;
className?: string;
}
export function CommentList({
comments,
trackId,
className,
}: CommentListProps) {
return (
<motion.ul
className={cn('space-y-4 list-none p-0 m-0', className)}
variants={listVariants}
initial="hidden"
animate="visible"
data-testid="comment-list"
>
{comments.map((comment) => (
<motion.li key={comment.id} variants={itemVariants}>
<CommentThread comment={comment} trackId={trackId} />
</motion.li>
))}
</motion.ul>
);
}

View file

@ -0,0 +1,193 @@
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>
);
}

View file

@ -0,0 +1,19 @@
import { cn } from '@/lib/utils';
export interface CommentSectionEmptyProps {
className?: string;
}
export function CommentSectionEmpty({ className }: CommentSectionEmptyProps) {
return (
<div
className={cn(
'text-center text-muted-foreground py-8 text-sm',
className,
)}
data-testid="comment-section-empty"
>
Aucun commentaire pour le moment. Soyez le premier à commenter !
</div>
);
}

View file

@ -0,0 +1,32 @@
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { cn } from '@/lib/utils';
export interface CommentSectionErrorProps {
error: Error;
resourceId?: string;
onRetry?: () => void;
className?: string;
}
export function CommentSectionError({
error,
resourceId,
onRetry,
className,
}: CommentSectionErrorProps) {
return (
<div className={cn(className)} data-testid="comment-section-error">
<ErrorDisplay
error={error}
variant="card"
severity="error"
context={{
action: 'fetching comments',
resource: 'comments',
resourceId,
}}
onRetry={onRetry}
/>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { MessageCircle } from 'lucide-react';
import { CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
export interface CommentSectionHeaderProps {
count: number;
className?: string;
}
export function CommentSectionHeader({
count,
className,
}: CommentSectionHeaderProps) {
return (
<CardHeader className={cn(className)}>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="h-5 w-5" />
Commentaires ({count})
</CardTitle>
</CardHeader>
);
}

View file

@ -0,0 +1,48 @@
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export interface CommentSectionPaginationProps {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
}
export function CommentSectionPagination({
page,
totalPages,
onPageChange,
className,
}: CommentSectionPaginationProps) {
if (totalPages <= 1) return null;
return (
<div
className={cn(
'flex items-center justify-center gap-2 pt-4',
className,
)}
data-testid="comment-section-pagination"
>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(1, page - 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={() => onPageChange(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
>
Suivant
</Button>
</div>
);
}

View file

@ -0,0 +1,28 @@
import { CommentThreadSkeleton } from '../comment-thread';
import { cn } from '@/lib/utils';
export interface CommentSectionSkeletonProps {
/** Number of skeleton rows (default 4) */
rows?: number;
className?: string;
}
/**
* Skeleton for the full comment section: mimics 34 comment rows.
* Uses only Tailwind spacing scale and layout primitives.
*/
export function CommentSectionSkeleton({
rows = 4,
className,
}: CommentSectionSkeletonProps) {
return (
<div
className={cn('space-y-4', className)}
data-testid="comment-section-skeleton"
>
{Array.from({ length: Math.min(Math.max(rows, 1), 6) }).map((_, i) => (
<CommentThreadSkeleton key={i} />
))}
</div>
);
}

View file

@ -0,0 +1,8 @@
export { CommentSection } from './CommentSection';
export { CommentSectionHeader } from './CommentSectionHeader';
export { CommentSectionSkeleton } from './CommentSectionSkeleton';
export { CommentSectionEmpty } from './CommentSectionEmpty';
export { CommentSectionError } from './CommentSectionError';
export { CommentEditor } from './CommentEditor';
export { CommentList } from './CommentList';
export { CommentSectionPagination } from './CommentSectionPagination';