veza/apps/web/src/features/playlists/components/PlaylistFollowButton.tsx
senke 9a4c0d2af4 feat(web): update all features, stories, e2e tests, and auth interceptor
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>
2026-03-31 19:16:36 +02:00

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