veza/apps/web/src/features/tracks/components/LikeButton.tsx
senke 64fbb81ddf ui(design): Phase 3 - rounded tokens, min-w/min-h, stories, NavigationProgress
- rounded-[var(--radius-xl/md/lg/sm)] → rounded-xl, rounded-md, rounded-lg, rounded-sm
- Timeline: min-w-[200px] → min-w-50
- AddEquipmentView, MetadataForm: min-h-[100px] → min-h-25
- NavigationProgress: shadow-[...] → shadow-button-primary-glow
- Stories: ActivityGraph, StatCard, NotificationBell, LoadingState, ScrollArea, Skeleton, FileUploadZone
- Reduced arbitrary values from ~60+ to 11 (5 files, exceptions documented)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 19:24:07 +01:00

210 lines
6 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Heart, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
likeTrack,
unlikeTrack,
getTrackLikes,
} from '../services/interactionService';
import { useToast } from '@/hooks/useToast';
import { useUser } from '@/features/auth/hooks/useUser';
import { useIsRateLimited } from '@/hooks/useIsRateLimited';
/**
* FE-COMP-016: Like/Unlike button component for tracks with count display
*/
interface LikeButtonProps {
trackId: string;
initialLikeCount?: number;
initialIsLiked?: boolean;
onLikeChange?: (isLiked: boolean, count: number) => void;
className?: string;
size?: 'default' | 'sm' | 'lg' | 'icon';
variant?: 'default' | 'outline' | 'ghost';
showCount?: boolean;
compact?: boolean;
}
export function LikeButton({
trackId,
initialLikeCount,
initialIsLiked = false,
onLikeChange,
className,
size = 'default',
variant = 'ghost',
showCount = true,
compact = false,
}: LikeButtonProps) {
const { data: user } = useUser();
const { success: showSuccess, error: showError } = useToast();
const queryClient = useQueryClient();
const isRateLimited = useIsRateLimited();
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [likeCount, setLikeCount] = useState(initialLikeCount ?? 0);
const [isUpdating, setIsUpdating] = useState(false);
const [animating, setAnimating] = useState(false);
// Fetch like status from API
const { data: likesData } = useQuery({
queryKey: ['trackLikes', trackId],
queryFn: () => getTrackLikes(trackId),
enabled: !!trackId && !!user,
staleTime: 30000, // 30 seconds
retry: false,
});
// Update state from API response
useEffect(() => {
if (likesData) {
setIsLiked(likesData.isLiked);
setLikeCount(likesData.count);
} else if (initialIsLiked !== undefined) {
setIsLiked(initialIsLiked);
}
if (initialLikeCount !== undefined) {
setLikeCount(initialLikeCount);
}
}, [likesData, initialIsLiked, initialLikeCount]);
// Like mutation
const likeMutation = useMutation({
mutationFn: () => likeTrack(trackId),
onMutate: async () => {
// Optimistic update
setIsLiked(true);
setLikeCount((prev) => prev + 1);
setIsUpdating(true);
},
onSuccess: () => {
showSuccess('Ajouté aux favoris');
onLikeChange?.(true, likeCount + 1);
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['trackLikes', trackId] });
queryClient.invalidateQueries({ queryKey: ['tracks'] });
},
onError: (error: any) => {
// Revert optimistic update
setIsLiked(false);
setLikeCount((prev) => Math.max(0, prev - 1));
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
"Erreur lors de l'ajout aux favoris";
showError(errorMessage);
},
onSettled: () => {
setIsUpdating(false);
},
});
// Unlike mutation
const unlikeMutation = useMutation({
mutationFn: () => unlikeTrack(trackId),
onMutate: async () => {
// Optimistic update
setIsLiked(false);
setLikeCount((prev) => Math.max(0, prev - 1));
setIsUpdating(true);
},
onSuccess: () => {
showSuccess('Retiré des favoris');
onLikeChange?.(false, Math.max(0, likeCount - 1));
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ['trackLikes', trackId] });
queryClient.invalidateQueries({ queryKey: ['tracks'] });
},
onError: (error: any) => {
// Revert optimistic update
setIsLiked(true);
setLikeCount((prev) => prev + 1);
const errorMessage =
error.response?.data?.error?.message ||
error.response?.data?.message ||
error.message ||
'Erreur lors du retrait des favoris';
showError(errorMessage);
},
onSettled: () => {
setIsUpdating(false);
},
});
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isUpdating || !user || isRateLimited) return;
// Trigger bounce animation on like (not unlike)
if (!isLiked) {
setAnimating(true);
setTimeout(() => setAnimating(false), 400);
}
if (isLiked) {
unlikeMutation.mutate();
} else {
likeMutation.mutate();
}
};
// Don't show button if user is not logged in
if (!user) {
return null;
}
const isLoading =
likeMutation.isPending || unlikeMutation.isPending || isUpdating;
return (
<Button
onClick={handleClick}
disabled={isLoading || isRateLimited}
variant={variant}
size={size}
className={cn(
'rounded-md transition-[color,transform] duration-[var(--duration-normal)]',
className,
isLiked && 'text-destructive hover:text-destructive/90',
compact && 'h-auto p-1',
)}
aria-label={isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris'}
aria-pressed={isLiked}
>
{isLoading ? (
<>
<Loader2
className={cn('h-4 w-4 animate-spin', showCount && 'mr-2')}
/>
{!compact && showCount && (
<span className="tabular-nums tracking-tight">{likeCount}</span>
)}
</>
) : (
<>
<Heart
className={cn(
'h-4 w-4 transition-colors duration-[var(--duration-normal)]',
animating && 'animate-like-bounce',
isLiked && 'fill-current',
showCount && 'mr-2',
)}
/>
{showCount && (
<span
className={cn(
'tabular-nums tracking-tight',
compact && 'text-xs',
)}
>
{likeCount > 0 ? likeCount : ''}
</span>
)}
</>
)}
</Button>
);
}