feat(comments): add high-fidelity skeletons and Framer Motion transitions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-07 07:32:49 +01:00
parent 1006cc7e3a
commit a65e4b7ab2
4 changed files with 69 additions and 37 deletions

View file

@ -1,4 +1,4 @@
import { Loader2 } from 'lucide-react';
import { CommentThreadSkeleton } from './CommentThreadSkeleton';
import { cn } from '@/lib/utils';
export interface CommentRepliesListProps {
@ -21,11 +21,9 @@ export function CommentRepliesList({
data-testid="comment-replies-list"
>
{isLoading ? (
<div
className="flex items-center justify-center py-4"
data-testid="replies-loading"
>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<div className="space-y-2" data-testid="replies-loading">
<CommentThreadSkeleton />
<CommentThreadSkeleton />
</div>
) : (
children

View file

@ -4,6 +4,7 @@
*/
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Avatar } from '@/components/ui/avatar';
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
import { useUser } from '@/features/auth/hooks/useUser';
@ -139,18 +140,28 @@ export function CommentThread({
/>
)}
{showReplies && (
<CommentRepliesList isLoading={isLoadingReplies}>
{replies.map((reply) => (
<CommentThread
key={reply.id}
comment={reply}
trackId={trackId}
depth={depth + 1}
/>
))}
</CommentRepliesList>
)}
<AnimatePresence initial={false}>
{showReplies && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="overflow-hidden"
>
<CommentRepliesList isLoading={isLoadingReplies}>
{replies.map((reply) => (
<CommentThread
key={reply.id}
comment={reply}
trackId={trackId}
depth={depth + 1}
/>
))}
</CommentRepliesList>
</motion.div>
)}
</AnimatePresence>
</>
)}
</div>

View file

@ -1,7 +1,14 @@
import { motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { MessageCircle, Reply } from 'lucide-react';
import { cn } from '@/lib/utils';
const actionMotion = {
rest: { scale: 1 },
hover: { scale: 1.03 },
tap: { scale: 0.97 },
};
export interface CommentThreadActionsProps {
canReply: boolean;
hasUser: boolean;
@ -27,27 +34,43 @@ export function CommentThreadActions({
data-testid="comment-thread-actions"
>
{canReply && hasUser && (
<Button
variant="ghost"
size="sm"
onClick={onToggleReply}
className="h-7 text-xs"
<motion.div
initial="rest"
whileHover="hover"
whileTap="tap"
variants={actionMotion}
transition={{ duration: 0.15 }}
>
<Reply className="h-3 w-3 mr-1" />
Répondre
</Button>
<Button
variant="ghost"
size="sm"
onClick={onToggleReply}
className="h-7 text-xs hover:text-primary"
>
<Reply className="h-3 w-3 mr-1" />
Répondre
</Button>
</motion.div>
)}
{repliesCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onToggleReplies}
className="h-7 text-xs"
<motion.div
initial="rest"
whileHover="hover"
whileTap="tap"
variants={actionMotion}
transition={{ duration: 0.15 }}
>
<MessageCircle className="h-3 w-3 mr-1" />
{showReplies ? 'Masquer' : 'Afficher'} {repliesCount}{' '}
{repliesCount === 1 ? 'réponse' : 'réponses'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={onToggleReplies}
className="h-7 text-xs hover:text-primary"
>
<MessageCircle className="h-3 w-3 mr-1" />
{showReplies ? 'Masquer' : 'Afficher'} {repliesCount}{' '}
{repliesCount === 1 ? 'réponse' : 'réponses'}
</Button>
</motion.div>
)}
</div>
);

View file

@ -22,8 +22,8 @@ export function CommentThreadSkeleton({
<div className="h-4 w-24 rounded bg-muted" />
<div className="h-3 w-16 rounded bg-muted" />
</div>
<div className="h-4 w-full max-w-md rounded bg-muted" />
<div className="h-4 w-3/4 max-w-sm rounded bg-muted" />
<div className="h-4 w-full rounded bg-muted" />
<div className="h-4 w-2/3 rounded bg-muted" />
</div>
</div>
);