2025-12-25 11:04:49 +00:00
|
|
|
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';
|
2026-01-13 18:47:57 +00:00
|
|
|
import {
|
|
|
|
|
likeTrack,
|
|
|
|
|
unlikeTrack,
|
|
|
|
|
getTrackLikes,
|
|
|
|
|
} from '../services/interactionService';
|
2025-12-25 11:04:49 +00:00
|
|
|
import { useToast } from '@/hooks/useToast';
|
state-ownership: replace all useAuthStore().user with useUser() hook
- Migrated all hooks: useAuth, useChat, useLogin
- Migrated all components: Header, ProfileForm, FollowButton, LikeButton, PlaylistFollowButton, ChatMessage, ChatMessages, CommentThread, CommentSection, PlaylistList, ChatSidebar, SettingsPage, DashboardPage
- Updated storeSelectors.ts useAuthUser() to use React Query
- All production code now uses useUser() hook instead of Zustand store
- Action 4.1.1.3 and 4.1.1.4 complete
2026-01-14 00:45:42 +00:00
|
|
|
import { useUser } from '@/features/auth/hooks/useUser';
|
2026-01-15 19:01:47 +00:00
|
|
|
import { useIsRateLimited } from '@/hooks/useIsRateLimited';
|
2025-12-25 11:04:49 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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) {
|
state-ownership: replace all useAuthStore().user with useUser() hook
- Migrated all hooks: useAuth, useChat, useLogin
- Migrated all components: Header, ProfileForm, FollowButton, LikeButton, PlaylistFollowButton, ChatMessage, ChatMessages, CommentThread, CommentSection, PlaylistList, ChatSidebar, SettingsPage, DashboardPage
- Updated storeSelectors.ts useAuthUser() to use React Query
- All production code now uses useUser() hook instead of Zustand store
- Action 4.1.1.3 and 4.1.1.4 complete
2026-01-14 00:45:42 +00:00
|
|
|
const { data: user } = useUser();
|
2025-12-25 11:04:49 +00:00
|
|
|
const { success: showSuccess, error: showError } = useToast();
|
|
|
|
|
const queryClient = useQueryClient();
|
2026-01-15 19:01:47 +00:00
|
|
|
const isRateLimited = useIsRateLimited();
|
2025-12-25 11:04:49 +00:00
|
|
|
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) {
|
2026-01-07 18:39:21 +00:00
|
|
|
setIsLiked(likesData.isLiked);
|
2025-12-25 11:04:49 +00:00
|
|
|
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 ||
|
2026-01-13 18:47:57 +00:00
|
|
|
"Erreur lors de l'ajout aux favoris";
|
2025-12-25 11:04:49 +00:00
|
|
|
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();
|
2026-01-15 19:01:47 +00:00
|
|
|
if (isUpdating || !user || isRateLimited) return;
|
2025-12-25 11:04:49 +00:00
|
|
|
|
|
|
|
|
if (isLiked) {
|
|
|
|
|
unlikeMutation.mutate();
|
|
|
|
|
} else {
|
|
|
|
|
likeMutation.mutate();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Don't show button if user is not logged in
|
|
|
|
|
if (!user) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
const isLoading =
|
|
|
|
|
likeMutation.isPending || unlikeMutation.isPending || isUpdating;
|
2025-12-25 11:04:49 +00:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleClick}
|
2026-01-15 19:01:47 +00:00
|
|
|
disabled={isLoading || isRateLimited}
|
2025-12-25 11:04:49 +00:00
|
|
|
variant={variant}
|
|
|
|
|
size={size}
|
|
|
|
|
className={cn(
|
|
|
|
|
className,
|
2026-01-16 00:57:01 +00:00
|
|
|
isLiked && 'text-kodo-red hover:text-kodo-red',
|
2025-12-25 11:04:49 +00:00
|
|
|
compact && 'h-auto p-1',
|
|
|
|
|
)}
|
|
|
|
|
aria-label={isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris'}
|
|
|
|
|
aria-pressed={isLiked}
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<>
|
2026-01-13 18:47:57 +00:00
|
|
|
<Loader2
|
|
|
|
|
className={cn('h-4 w-4 animate-spin', showCount && 'mr-2')}
|
|
|
|
|
/>
|
2025-12-25 11:04:49 +00:00
|
|
|
{!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>
|
|
|
|
|
);
|
|
|
|
|
}
|