Phase 1:
- S0: Fix open redirect (safeNavigate), delete AuthContext/legacy auth, encrypt API keys, gitignore .env files
- S1: Split client.ts god object into 5 modules, unify toast system, delete unused Sidebar
- S2: Add glass button variant, migrate 32 z-index to SUMI tokens, fix card dark mode
- S3: Skip nav link, aria-hidden on icons, focus-visible ring fixes, alt attrs, aria-live regions
- S4: React.memo on list items, fix key={index}, loading=lazy on images
- S5: Branded loading screen, page transitions respect reduced-motion, LikeButton micro-interaction, i18n sidebar/header
Phase 2 Sprint 6:
- Wire Tailwind shadow utilities to SUMI tokens in @theme block (fixes 50+ files)
- Define shadow-card/shadow-card-hover tokens
- Remove dark:shadow-none workarounds from card.tsx (SUMI handles per-theme shadows)
Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
6 KiB
TypeScript
211 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(--sumi-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(--sumi-duration-normal)]',
|
|
animating && 'animate-like-bounce drop-shadow-[0_0_8px_var(--sumi-vermillion)]',
|
|
isLiked && 'fill-current',
|
|
showCount && 'mr-2',
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
{showCount && (
|
|
<span
|
|
className={cn(
|
|
'tabular-nums tracking-tight',
|
|
compact && 'text-xs',
|
|
)}
|
|
>
|
|
{likeCount > 0 ? likeCount : ''}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</Button>
|
|
);
|
|
}
|