[FE-COMP-016] fe-comp: Add track like/unlike button
This commit is contained in:
parent
420e22100c
commit
6d42a391e5
3 changed files with 208 additions and 22 deletions
|
|
@ -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",
|
||||
|
|
|
|||
186
apps/web/src/features/tracks/components/LikeButton.tsx
Normal file
186
apps/web/src/features/tracks/components/LikeButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue