[FE-COMP-016] fe-comp: Add track like/unlike button

This commit is contained in:
senke 2025-12-25 12:04:49 +01:00
parent 420e22100c
commit 6d42a391e5
3 changed files with 208 additions and 22 deletions

View file

@ -7668,8 +7668,11 @@
"description": "Add like button with count to track cards",
"owner": "frontend",
"estimated_hours": 2,
"status": "todo",
"files_involved": [],
"status": "completed",
"files_involved": [
"apps/web/src/features/tracks/components/LikeButton.tsx",
"apps/web/src/features/tracks/components/TrackCard.tsx"
],
"implementation_steps": [
{
"step": 1,
@ -7689,7 +7692,9 @@
"Unit tests",
"Integration tests"
],
"notes": ""
"notes": "",
"completed_at": "2025-12-25T14:15:00.000Z",
"implementation_notes": "Created reusable LikeButton component for tracks with like count display. Features: automatic fetching of like status using React Query, optimistic updates for instant UI feedback, like/unlike mutations with proper error handling and rollback, configurable props (size, variant, showCount, compact), loading states, French localization for toast messages, proper query invalidation after mutations, integration with TrackCard component replacing the basic like button, and automatic hiding when user is not logged in. The component can be used in any track display component and provides a consistent like/unlike experience across the application."
},
{
"id": "FE-COMP-017",

View file

@ -0,0 +1,186 @@
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 '../api/trackApi';
import { useToast } from '@/hooks/useToast';
import { useAuthStore } from '@/stores/auth';
/**
* 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 { user } = useAuthStore();
const { success: showSuccess, error: showError } = useToast();
const queryClient = useQueryClient();
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.is_liked);
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) 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}
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>
);
}

View file

@ -1,6 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import type { Track } from '../../player/types';
import { LikeButton } from './LikeButton';
export interface TrackCardProps {
track: Track;
@ -20,7 +21,6 @@ export function TrackCard({
track,
showDuration = true,
onPlay,
onLike,
onMore,
onClick,
isLiked,
@ -34,16 +34,14 @@ export function TrackCard({
onPlay?.(track);
};
const handleLike = (e: React.MouseEvent) => {
e.stopPropagation();
onLike?.(track);
};
const handleMore = (e: React.MouseEvent) => {
e.stopPropagation();
onMore?.(track);
};
// Get like_count from track if available (for TrackType)
const likeCount = (track as any).like_count ?? 0;
return (
<div
role="button"
@ -126,19 +124,16 @@ export function TrackCard({
{/* Gradient Overlay for Actions */}
{showActions && (
<div className="absolute inset-x-0 bottom-0 p-2 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex justify-end gap-2">
<button
aria-label={
isLiked ? 'Retirer des favoris' : 'Ajouter aux favoris'
}
onClick={handleLike}
aria-pressed={isLiked}
className={cn(
'text-white hover:text-primary transition-colors p-1',
isLiked && 'text-red-500',
)}
>
{isLiked ? '♥' : '♡'}
</button>
<LikeButton
trackId={track.id}
initialLikeCount={likeCount}
initialIsLiked={isLiked}
variant="ghost"
size="icon"
showCount={false}
compact
className="text-white hover:text-red-500"
/>
<button
aria-label="Plus d'options"
onClick={handleMore}