veza/apps/web/src/features/tracks/components/LikeButton.tsx
senke 5f88c56113 fix: UI remediation Phase 1 (S0-S5) + Phase 2 Sprint 6 shadow system
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>
2026-02-12 10:13:44 +01:00

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>
);
}