veza/apps/web/src/features/tracks/components/LikeButton.tsx
senke c23bad2099 security: disable mutation buttons when rate limited
- Created useIsRateLimited() hook to check rate limit state
- Updated CommentSection submit button to disable when rate limited
- Updated LikeButton to disable when rate limited
- Updated PlaylistForm submit button to disable when rate limited
- Updated ChatInput send button to disable when rate limited
- Updated UploadModal upload button to disable when rate limited
- All buttons check isLimited from rate limit store
- Hook uses Zustand selector for efficient re-renders
- Pattern established for future mutation buttons
- Action 5.4.1.4 complete
2026-01-15 20:01:47 +01:00

194 lines
5.4 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);
// 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;
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(
className,
isLiked && 'text-red-500 hover:text-red-600',
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>{likeCount}</span>}
</>
) : (
<>
<Heart
className={cn(
'h-4 w-4',
isLiked && 'fill-current',
showCount && 'mr-2',
)}
/>
{showCount && (
<span className={cn(compact && 'text-xs')}>
{likeCount > 0 ? likeCount : ''}
</span>
)}
</>
)}
</Button>
);
}