Update auth, playlists, tracks, search, profile, dashboard, player, settings, and social features. Add e2e audit specs for all major pages. Update ESLint config, vitest config, and route configuration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
209 lines
6.6 KiB
TypeScript
209 lines
6.6 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Button } from '@/components/ui/button';
|
|
import { UserPlus, UserCheck } from 'lucide-react';
|
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
followPlaylist,
|
|
unfollowPlaylist,
|
|
getPlaylist,
|
|
getPlaylistFollowStatus,
|
|
} from '../services/playlistService';
|
|
import type { Playlist } from '../types';
|
|
import { useToast } from '@/hooks/useToast';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useUser } from '@/features/auth/hooks/useUser';
|
|
|
|
/**
|
|
* FE-COMP-017: Follow/Unfollow button component for playlists
|
|
*/
|
|
|
|
interface PlaylistFollowButtonProps {
|
|
playlistId: string;
|
|
initialFollowing?: boolean;
|
|
initialFollowerCount?: number;
|
|
onFollowChange?: (isFollowing: boolean) => void;
|
|
className?: string;
|
|
size?: 'default' | 'sm' | 'lg' | 'icon';
|
|
variant?: 'default' | 'outline' | 'ghost';
|
|
showCount?: boolean;
|
|
}
|
|
|
|
export function PlaylistFollowButton({
|
|
playlistId,
|
|
initialFollowing = false,
|
|
initialFollowerCount = 0,
|
|
onFollowChange,
|
|
className,
|
|
size = 'default',
|
|
variant,
|
|
showCount = false,
|
|
}: PlaylistFollowButtonProps) {
|
|
const { t } = useTranslation();
|
|
const { data: user } = useUser();
|
|
const { success: showSuccess, error: showError } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [following, setFollowing] = useState(initialFollowing);
|
|
const [followerCount, setFollowerCount] = useState(initialFollowerCount);
|
|
const [isUpdating, setIsUpdating] = useState(false);
|
|
|
|
// Fetch playlist to get current follow status
|
|
const { data: playlist } = useQuery<Playlist>({
|
|
queryKey: ['playlist', playlistId],
|
|
queryFn: () => getPlaylist(playlistId),
|
|
enabled: !!playlistId && !!user,
|
|
staleTime: 30000, // 30 seconds
|
|
});
|
|
|
|
// Try to fetch follow status if available
|
|
const { data: followStatus } = useQuery({
|
|
queryKey: ['playlistFollowStatus', playlistId],
|
|
queryFn: () => getPlaylistFollowStatus(playlistId),
|
|
enabled: !!playlistId && !!user,
|
|
staleTime: 30000,
|
|
retry: false,
|
|
});
|
|
|
|
// Update state from API response
|
|
useEffect(() => {
|
|
if (followStatus) {
|
|
setFollowing(followStatus.is_following);
|
|
setFollowerCount(followStatus.follower_count);
|
|
} else if (playlist && playlist.is_following !== undefined) {
|
|
setFollowing(playlist.is_following);
|
|
} else if (initialFollowing !== undefined) {
|
|
setFollowing(initialFollowing);
|
|
}
|
|
if (playlist && playlist.follower_count !== undefined) {
|
|
setFollowerCount(playlist.follower_count);
|
|
} else if (initialFollowerCount !== undefined) {
|
|
setFollowerCount(initialFollowerCount);
|
|
}
|
|
}, [followStatus, playlist, initialFollowing, initialFollowerCount]);
|
|
|
|
// Follow mutation
|
|
const followMutation = useMutation({
|
|
mutationFn: () => followPlaylist(playlistId),
|
|
onMutate: async () => {
|
|
// Optimistic update
|
|
setFollowing(true);
|
|
setFollowerCount((prev) => prev + 1);
|
|
setIsUpdating(true);
|
|
},
|
|
onSuccess: () => {
|
|
showSuccess(t('playlists.followBtn.followSuccess'));
|
|
onFollowChange?.(true);
|
|
// Invalidate queries to refresh data
|
|
queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] });
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['playlistFollowStatus', playlistId],
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['playlists'] });
|
|
},
|
|
onError: (error: unknown) => {
|
|
// Revert optimistic update
|
|
setFollowing(false);
|
|
setFollowerCount((prev) => Math.max(0, prev - 1));
|
|
const err = error as { response?: { data?: { error?: { message?: string }; message?: string } }; message?: string };
|
|
const errorMessage =
|
|
err.response?.data?.error?.message ||
|
|
err.response?.data?.message ||
|
|
err.message ||
|
|
t('playlists.followBtn.followError');
|
|
showError(errorMessage);
|
|
},
|
|
onSettled: () => {
|
|
setIsUpdating(false);
|
|
},
|
|
});
|
|
|
|
// Unfollow mutation
|
|
const unfollowMutation = useMutation({
|
|
mutationFn: () => unfollowPlaylist(playlistId),
|
|
onMutate: async () => {
|
|
// Optimistic update
|
|
setFollowing(false);
|
|
setFollowerCount((prev) => Math.max(0, prev - 1));
|
|
setIsUpdating(true);
|
|
},
|
|
onSuccess: () => {
|
|
showSuccess(t('playlists.followBtn.unfollowSuccess'));
|
|
onFollowChange?.(false);
|
|
// Invalidate queries to refresh data
|
|
queryClient.invalidateQueries({ queryKey: ['playlist', playlistId] });
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['playlistFollowStatus', playlistId],
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['playlists'] });
|
|
},
|
|
onError: (error: unknown) => {
|
|
// Revert optimistic update
|
|
setFollowing(true);
|
|
setFollowerCount((prev) => prev + 1);
|
|
const err = error as { response?: { data?: { error?: { message?: string }; message?: string } }; message?: string };
|
|
const errorMessage =
|
|
err.response?.data?.error?.message ||
|
|
err.response?.data?.message ||
|
|
err.message ||
|
|
t('playlists.followBtn.unfollowError');
|
|
showError(errorMessage);
|
|
},
|
|
onSettled: () => {
|
|
setIsUpdating(false);
|
|
},
|
|
});
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (isUpdating || !user) return;
|
|
|
|
if (following) {
|
|
unfollowMutation.mutate();
|
|
} else {
|
|
followMutation.mutate();
|
|
}
|
|
};
|
|
|
|
// Don't show follow button if user is not logged in or is the owner
|
|
if (!user || user.id === playlist?.user_id) {
|
|
return null;
|
|
}
|
|
|
|
const isLoading =
|
|
followMutation.isPending || unfollowMutation.isPending || isUpdating;
|
|
const buttonVariant = variant || (following ? 'outline' : 'default');
|
|
|
|
return (
|
|
<Button
|
|
onClick={handleClick}
|
|
disabled={isLoading}
|
|
variant={buttonVariant}
|
|
size={size}
|
|
className={cn(className, 'min-w-24')}
|
|
>
|
|
{isLoading ? (
|
|
<>
|
|
<LoadingSpinner size="sm" inline className="mr-2" />
|
|
{following ? t('playlists.followBtn.unfollowing') : t('playlists.followBtn.subscribing')}
|
|
</>
|
|
) : following ? (
|
|
<>
|
|
<UserCheck className="h-4 w-4 mr-2" />
|
|
{t('playlists.followBtn.following')}
|
|
{showCount && followerCount > 0 && (
|
|
<span className="ml-2 text-xs">({followerCount})</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<UserPlus className="h-4 w-4 mr-2" />
|
|
{t('playlists.followBtn.follow')}
|
|
{showCount && followerCount > 0 && (
|
|
<span className="ml-2 text-xs">({followerCount})</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</Button>
|
|
);
|
|
}
|