refactor(comments): modularize CommentSection with atomic sub-components
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
17b57dc885
commit
65e8a69db2
10 changed files with 464 additions and 267 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 3–4 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
Loading…
Reference in a new issue