Files
myListBridgeFront/src/pages/VibePage.tsx
bcjang ae33fb9bce 초기 커밋
바이브 > 스포티파이
2025-11-28 16:12:32 +09:00

854 lines
38 KiB
TypeScript

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { ArrowLeft, LogIn, Link as LinkIcon, Loader2, AlertCircle, Music, ArrowRight, ChevronRight, ChevronDown } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import Alert from "../utils/alert";
import vibeService from "../services/vibeService";
import spotifyService from "../services/spotifyService";
import type { VibePlaylist, VibeTrack } from "../types/api";
import { Alert as AlertUI, AlertDescription } from "../components/ui/alert";
import { Checkbox } from "../components/ui/checkbox";
import { ScrollArea } from "../components/ui/scroll-area";
import { Badge } from "../components/ui/badge";
import { Separator } from "../components/ui/separator";
import { ServiceCard } from "../components/features/service/ServiceCard";
import { streamingServices } from "../constants/streamingServices";
import { SpotifyPlaylistSelector } from "../components/features/spotify/SpotifyPlaylistSelector";
import type { SpotifyPlaylist, SpotifyTrackInfo } from "../types/api";
enum TransferStep {
LOGIN = "login",
SELECT_TRACKS_TREE = "select_tracks_tree",
SELECT_TARGET_SERVICE = "select_target_service",
SELECT_SPOTIFY_PLAYLIST = "select_spotify_playlist",
}
// 확장된 플레이리스트 타입
interface ExpandedPlaylist extends VibePlaylist {
expanded: boolean;
tracks: VibeTrack[];
isLoadingTracks: boolean;
}
export function VibePage() {
const navigate = useNavigate();
// Step 관리
const [currentStep, setCurrentStep] = useState<TransferStep>(TransferStep.LOGIN);
// 로그인 관련
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [url, setUrl] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 플레이리스트 관련 (트리 구조)
const [playlists, setPlaylists] = useState<ExpandedPlaylist[]>([]);
// 선택된 플레이리스트 및 트랙
const [selectedPlaylistIds, setSelectedPlaylistIds] = useState<Set<string>>(new Set());
const [selectedTrackIds, setSelectedTrackIds] = useState<Set<number>>(new Set());
// 대상 서비스
const [targetService, setTargetService] = useState<string | null>(null);
// Spotify 관련
const [spotifyPlaylists, setSpotifyPlaylists] = useState<SpotifyPlaylist[]>([]);
const [isLoadingSpotifyPlaylists, setIsLoadingSpotifyPlaylists] = useState(false);
const [isAddingTracks, setIsAddingTracks] = useState(false);
const handleLogin = async () => {
if (!email || !password) {
setError("아이디와 비밀번호를 입력해주세요.");
return;
}
setError(null);
setIsLoading(true);
try {
const response = await vibeService.login({ id: email, password });
if (response.success && response.data?.success) {
const playlistsData = response.data.playlists.map(p => ({
...p,
expanded: false,
tracks: [],
isLoadingTracks: false,
}));
setPlaylists(playlistsData);
setCurrentStep(TransferStep.SELECT_TRACKS_TREE);
Alert.success(`${playlistsData.length}개의 플레이리스트를 찾았습니다!`);
} else {
const errorMsg = response.data?.message || response.error || "로그인에 실패했습니다.";
setError(errorMsg);
Alert.error(errorMsg);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "로그인 중 오류가 발생했습니다.";
setError(errorMessage);
Alert.error(errorMessage);
} finally {
setIsLoading(false);
}
};
const handleUrlSubmit = async () => {
if (!url) {
setError("플레이리스트 URL을 입력해주세요.");
return;
}
setError(null);
setIsLoading(true);
try {
const response = await vibeService.getPlaylistByUrl({ url });
if (response.success && response.data) {
const playlistData = {
...response.data,
expanded: false,
tracks: [],
isLoadingTracks: false,
};
setPlaylists([playlistData]);
setCurrentStep(TransferStep.SELECT_TRACKS_TREE);
Alert.success("플레이리스트를 성공적으로 가져왔습니다!");
} else {
setError(response.error || "플레이리스트를 가져오는데 실패했습니다.");
Alert.error(response.error || "플레이리스트를 가져오는데 실패했습니다.");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "URL 처리 중 오류가 발생했습니다.";
setError(errorMessage);
Alert.error(errorMessage);
} finally {
setIsLoading(false);
}
};
// 플레이리스트 확장/축소 및 트랙 로드
const handlePlaylistToggle = async (playlistId: string) => {
const playlist = playlists.find(p => p.playlist_id === playlistId);
if (!playlist) return;
// 이미 확장된 경우 축소
if (playlist.expanded) {
setPlaylists(prev => prev.map(p =>
p.playlist_id === playlistId ? { ...p, expanded: false } : p
));
return;
}
// 트랙이 이미 로드된 경우 확장만
if (playlist.tracks.length > 0) {
setPlaylists(prev => prev.map(p =>
p.playlist_id === playlistId ? { ...p, expanded: true } : p
));
return;
}
// 트랙 로드
setPlaylists(prev => prev.map(p =>
p.playlist_id === playlistId ? { ...p, isLoadingTracks: true } : p
));
try {
const response = await vibeService.getPlaylistTracks(playlistId);
// 401 Unauthorized - 세션 만료
if (response.status === 401) {
vibeService.logout();
Alert.error("세션이 만료되었습니다. 다시 로그인해주세요.");
setPlaylists([]);
setSelectedPlaylistIds(new Set());
setSelectedTrackIds(new Set());
setCurrentStep(TransferStep.LOGIN);
return;
}
if (response.success && response.data?.success) {
setPlaylists(prev => prev.map(p =>
p.playlist_id === playlistId
? { ...p, tracks: response.data!.tracks, expanded: true, isLoadingTracks: false }
: p
));
} else {
const errorMsg = response.data?.message || response.error || "트랙을 가져오는데 실패했습니다.";
Alert.error(errorMsg);
setPlaylists(prev => prev.map(p =>
p.playlist_id === playlistId ? { ...p, isLoadingTracks: false } : p
));
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "트랙 조회 중 오류가 발생했습니다.";
Alert.error(errorMessage);
setPlaylists(prev => prev.map(p =>
p.playlist_id === playlistId ? { ...p, isLoadingTracks: false } : p
));
}
};
// 플레이리스트 체크/해제
const handlePlaylistCheck = (playlistId: string, e: React.MouseEvent) => {
e.stopPropagation();
const playlist = playlists.find(p => p.playlist_id === playlistId);
if (!playlist) return;
const isChecked = selectedPlaylistIds.has(playlistId);
if (isChecked) {
// 플레이리스트 체크 해제 - 모든 하위 트랙도 해제
setSelectedPlaylistIds(prev => {
const newSet = new Set(prev);
newSet.delete(playlistId);
return newSet;
});
setSelectedTrackIds(prev => {
const newSet = new Set(prev);
playlist.tracks.forEach(track => newSet.delete(track.track_id));
return newSet;
});
} else {
// 플레이리스트 체크 - 모든 하위 트랙도 체크
setSelectedPlaylistIds(prev => new Set(prev).add(playlistId));
setSelectedTrackIds(prev => {
const newSet = new Set(prev);
playlist.tracks.forEach(track => newSet.add(track.track_id));
return newSet;
});
}
};
// 트랙 체크/해제
const handleTrackCheck = (playlistId: string, trackId: number, e: React.MouseEvent) => {
e.stopPropagation();
const playlist = playlists.find(p => p.playlist_id === playlistId);
if (!playlist) return;
setSelectedTrackIds(prev => {
const newSet = new Set(prev);
if (newSet.has(trackId)) {
newSet.delete(trackId);
} else {
newSet.add(trackId);
}
// 플레이리스트의 모든 트랙이 선택되었는지 확인
const allTracksSelected = playlist.tracks.every(t => newSet.has(t.track_id));
setSelectedPlaylistIds(prevPlaylists => {
const newPlaylistSet = new Set(prevPlaylists);
if (allTracksSelected && playlist.tracks.length > 0) {
newPlaylistSet.add(playlistId);
} else {
newPlaylistSet.delete(playlistId);
}
return newPlaylistSet;
});
return newSet;
});
};
const handleGoToServiceSelection = () => {
if (selectedTrackIds.size === 0) {
Alert.error("최소 1곡 이상 선택해주세요.");
return;
}
setCurrentStep(TransferStep.SELECT_TARGET_SERVICE);
};
const handleServiceClick = async (serviceName: string) => {
setTargetService(serviceName);
if (serviceName === "Spotify") {
try {
setIsLoading(true);
const authResponse = await spotifyService.login();
if (authResponse) {
console.log(authResponse);
Alert.success("Spotify에 로그인되었습니다!");
// 백엔드 API를 통해 플레이리스트 목록 가져오기
setIsLoadingSpotifyPlaylists(true);
const playlistsResponse = await spotifyService.getPlaylistsFromBackend();
if (playlistsResponse.success && playlistsResponse.data) {
console.log("Spotify 플레이리스트 응답:", playlistsResponse.data);
setSpotifyPlaylists(playlistsResponse.data.playlists);
setCurrentStep(TransferStep.SELECT_SPOTIFY_PLAYLIST);
} else {
Alert.error(playlistsResponse.error || "플레이리스트를 가져오는데 실패했습니다.");
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Spotify 로그인에 실패했습니다.";
Alert.error(errorMessage);
} finally {
setIsLoading(false);
setIsLoadingSpotifyPlaylists(false);
}
} else {
Alert.info(`${serviceName} 연동은 준비 중입니다.`);
}
};
const handleSpotifyPlaylistSelect = async (playlistId: string) => {
await addTracksToSpotify(playlistId, null);
};
const handleSpotifyCreateNew = async (playlistName: string, description?: string) => {
await addTracksToSpotify(null, playlistName, description);
};
const addTracksToSpotify = async (
playlistId: string | null,
playlistName?: string | null,
playlistDescription?: string
) => {
// 중복 클릭 방지
if (isAddingTracks) {
return;
}
try {
setIsLoading(true);
setIsAddingTracks(true);
// 선택된 트랙들을 Spotify API 형식으로 변환
const selectedTracks: SpotifyTrackInfo[] = [];
playlists.forEach((playlist) => {
playlist.tracks.forEach((track) => {
if (selectedTrackIds.has(track.track_id)) {
selectedTracks.push({
track_title: track.track_title,
artist_name: track.artists.map((a) => a.artist_name).join(', '),
album_title: track.album.album_title,
});
}
});
});
// API 호출 (토큰 유효성 검사는 서비스 내부에서 자동 처리됨)
const response = await spotifyService.addTracksToPlaylist({
access_token: '', // 서비스에서 자동으로 유효한 토큰으로 교체됨
playlist_id: playlistId,
playlist_name: playlistName || undefined,
playlist_description: playlistDescription,
is_public: false,
tracks: selectedTracks,
});
if (response.success && response.data) {
const message = playlistId
? `${response.data.added_count}곡을 플레이리스트에 추가했습니다!`
: `새 플레이리스트를 생성하고 ${response.data.added_count}곡을 추가했습니다!`;
Alert.success(message);
// 첫 페이지로 돌아가기
navigate('/');
} else {
Alert.error(response.error || "트랙 추가에 실패했습니다.");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "트랙 추가 중 오류가 발생했습니다.";
Alert.error(errorMessage);
} finally {
setIsLoading(false);
setIsAddingTracks(false);
}
};
const availableServices = streamingServices.filter(s => s.name !== "VIBE");
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-gray-50 to-purple-50/30 dark:from-gray-900 dark:via-gray-950 dark:to-blue-950/30">
{/* Header */}
<header className="bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg border-b dark:border-gray-800 sticky top-0 z-10 shadow-sm">
<div className="container mx-auto px-3 sm:px-4 py-3 sm:py-4">
<Button
variant="ghost"
onClick={() => navigate("/")}
className="gap-1.5 sm:gap-2 text-sm sm:text-base px-2 sm:px-4"
>
<ArrowLeft className="size-3.5 sm:size-4" />
</Button>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-3 sm:px-4 py-6 sm:py-12">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-2xl mx-auto"
>
<div className="text-center mb-6 sm:mb-8">
<div className="flex justify-center mb-3 sm:mb-4">
<div className="p-3 sm:p-4 bg-[#FC3C44]/10 rounded-2xl">
<img
src="https://vibe.naver.com/favicon.ico"
alt="VIBE"
className="size-12 sm:size-16"
/>
</div>
</div>
<h1 className="text-2xl sm:text-3xl font-bold mb-2">VIBE </h1>
<p className="text-muted-foreground text-sm sm:text-base px-4">
{currentStep === TransferStep.LOGIN && "VIBE 플레이리스트를 가져오기 위해 로그인하거나 URL을 입력하세요"}
{currentStep === TransferStep.SELECT_TRACKS_TREE && "이전할 플레이리스트와 곡을 선택하세요"}
{currentStep === TransferStep.SELECT_TARGET_SERVICE && "이전할 스트리밍 서비스를 선택하세요"}
{currentStep === TransferStep.SELECT_SPOTIFY_PLAYLIST && "Spotify 플레이리스트를 선택하거나 새로 생성하세요"}
</p>
</div>
{/* 에러 메시지 */}
{/*<AnimatePresence>*/}
{/* {error && (*/}
{/* <motion.div*/}
{/* initial={{ opacity: 0, y: -10 }}*/}
{/* animate={{ opacity: 1, y: 0 }}*/}
{/* exit={{ opacity: 0, y: -10 }}*/}
{/* className="mb-4"*/}
{/* >*/}
{/* <AlertUI variant="destructive">*/}
{/* <AlertCircle className="size-4" />*/}
{/* <AlertDescription>{error}</AlertDescription>*/}
{/* </AlertUI>*/}
{/* </motion.div>*/}
{/* )}*/}
{/*</AnimatePresence>*/}
{/* Step 1: 로그인 카드 */}
<AnimatePresence>
{currentStep === TransferStep.LOGIN && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="text-lg sm:text-xl">VIBE </CardTitle>
<CardDescription className="text-xs sm:text-sm">
URL을
</CardDescription>
</CardHeader>
<CardContent className="p-4 sm:p-6">
<Tabs defaultValue="login" className="w-full">
<TabsList className="grid w-full grid-cols-2 h-10 sm:h-11">
<TabsTrigger value="login" className="gap-1.5 sm:gap-2 text-xs sm:text-sm">
<LogIn className="size-3.5 sm:size-4" />
</TabsTrigger>
<TabsTrigger value="url" className="gap-1.5 sm:gap-2 text-xs sm:text-sm">
<LinkIcon className="size-3.5 sm:size-4" />
URL
</TabsTrigger>
</TabsList>
<TabsContent value="login" className="space-y-3 sm:space-y-4 mt-4 sm:mt-6">
<div className="space-y-1.5 sm:space-y-2">
<Label htmlFor="email" className="text-sm"></Label>
<Input
id="email"
type="text"
placeholder="아이디를 입력하세요"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
className="h-10 sm:h-11 text-sm sm:text-base"
/>
</div>
<div className="space-y-1.5 sm:space-y-2">
<Label htmlFor="password" className="text-sm"></Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
className="h-10 sm:h-11 text-sm sm:text-base"
/>
</div>
<Button
onClick={handleLogin}
className="w-full h-11 sm:h-12 text-sm sm:text-base"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="size-3.5 sm:size-4 mr-1.5 sm:mr-2 animate-spin" />
...
</>
) : (
<>
<LogIn className="size-3.5 sm:size-4 mr-1.5 sm:mr-2" />
<span className="hidden xs:inline"> </span>
<span className="xs:hidden"> & </span>
</>
)}
</Button>
</TabsContent>
<TabsContent value="url" className="space-y-3 sm:space-y-4 mt-4 sm:mt-6">
<div className="space-y-1.5 sm:space-y-2">
<Label htmlFor="url" className="text-sm"> URL</Label>
<Input
id="url"
type="url"
placeholder="https://vibe.naver.com/..."
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleUrlSubmit()}
className="h-10 sm:h-11 text-sm sm:text-base"
/>
</div>
<Button
onClick={handleUrlSubmit}
className="w-full h-11 sm:h-12 text-sm sm:text-base"
size="lg"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="size-3.5 sm:size-4 mr-1.5 sm:mr-2 animate-spin" />
...
</>
) : (
<>
<LinkIcon className="size-3.5 sm:size-4 mr-1.5 sm:mr-2" />
<span className="hidden xs:inline">URL로 </span>
<span className="xs:hidden">URL로 </span>
</>
)}
</Button>
</TabsContent>
</Tabs>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Step 2: 플레이리스트 및 트랙 트리 */}
<AnimatePresence>
{currentStep === TransferStep.SELECT_TRACKS_TREE && playlists.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<Card className="shadow-lg border-primary/10 dark:border-primary/20">
<CardHeader className="p-4 sm:p-6 bg-gradient-to-r from-[#FC3C44]/5 to-transparent dark:from-[#FC3C44]/10 dark:to-transparent">
<div className="flex items-center justify-between gap-3 mb-3">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-[#FC3C44]/10 rounded-lg">
<Music className="size-4 sm:size-5 text-[#FC3C44]" />
</div>
<span className="text-base sm:text-lg font-semibold"> </span>
</div>
{selectedTrackIds.size > 0 && (
<Button
onClick={handleGoToServiceSelection}
variant="ghost"
className="h-9 sm:h-10 text-xs sm:text-sm shadow-md"
>
<ArrowRight className="size-3.5 sm:size-4 mr-2" />
</Button>
)}
</div>
{selectedTrackIds.size > 0 && (
<div className="bg-[#FC3C44]/5 dark:bg-[#FC3C44]/10 rounded-lg px-4 py-3 border border-[#FC3C44]/10 dark:border-[#FC3C44]/20">
<div className="flex items-center gap-1.5 text-xs sm:text-sm">
<span className="text-muted-foreground"> </span>
<span className="font-semibold text-[#FC3C44]">{selectedTrackIds.size}</span>
</div>
</div>
)}
</CardHeader>
<CardContent className="p-3 sm:p-6">
<ScrollArea className="h-[500px] sm:h-[600px] pr-2 sm:pr-4">
<div className="space-y-2">
{playlists.map((playlist, index) => {
const isPlaylistChecked = selectedPlaylistIds.has(playlist.playlist_id);
const hasSelectedTracks = playlist.tracks.some(t => selectedTrackIds.has(t.track_id));
return (
<motion.div
key={playlist.playlist_id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.3,
delay: index * 0.05,
ease: "easeOut"
}}
>
{/* 플레이리스트 아이템 */}
<div
className={`flex items-center gap-3 p-3 sm:p-4 rounded-xl border transition-all cursor-pointer ${
isPlaylistChecked || hasSelectedTracks
? 'bg-[#FC3C44]/5 dark:bg-[#FC3C44]/10 border-[#FC3C44]/20 dark:border-[#FC3C44]/30'
: 'bg-white dark:bg-gray-900 hover:bg-gray-50/80 dark:hover:bg-gray-800/80 border-gray-200 dark:border-gray-700'
}`}
onClick={() => handlePlaylistToggle(playlist.playlist_id)}
>
{/* 확장/축소 아이콘 */}
<div className="shrink-0">
{playlist.isLoadingTracks ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : playlist.expanded ? (
<ChevronDown className="size-4 text-muted-foreground" />
) : (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</div>
{/* 체크박스 */}
<Checkbox
checked={isPlaylistChecked}
onClick={(e) => handlePlaylistCheck(playlist.playlist_id, e)}
className="data-[state=checked]:bg-[#FC3C44] data-[state=checked]:border-[#FC3C44] shrink-0"
/>
{/* 플레이리스트 이미지 */}
<div className="size-12 sm:size-14 rounded-lg overflow-hidden shrink-0 bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
{playlist.image_url ? (
<img
src={playlist.image_url}
alt={playlist.playlist_name}
className="size-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<Music className={`size-6 sm:size-7 ${playlist.image_url ? 'hidden' : ''} text-gray-400 dark:text-gray-500`} />
</div>
{/* 플레이리스트 정보 */}
<div className="flex-1 min-w-0">
<p className={`truncate text-sm sm:text-base font-medium ${
isPlaylistChecked ? 'text-[#FC3C44]' : 'text-foreground'
}`}>
{playlist.playlist_name}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Music className="size-3" />
{playlist.track_count}
</span>
</div>
</div>
</div>
{/* 트랙 목록 (확장 시) */}
<AnimatePresence>
{playlist.expanded && playlist.tracks.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="ml-7 sm:ml-10 mt-4 border-l-2 border-gray-200 dark:border-gray-700 space-y-2">
{playlist.tracks.map((track, trackIndex) => {
const isTrackChecked = selectedTrackIds.has(track.track_id);
return (
<div key={track.track_id} className="relative">
{/* 연결선 */}
<div className="absolute left-0 top-1/2 w-4 sm:w-6 border-t-2 border-gray-200 dark:border-gray-700"></div>
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2 }}
className={`ml-4 sm:ml-6 flex items-center gap-3 p-3 sm:p-4 pr-4 rounded-lg border transition-all cursor-pointer ${
isTrackChecked
? 'bg-primary/5 dark:bg-primary/10 border-primary/20'
: 'bg-gray-50/50 dark:bg-gray-900/80 hover:bg-gray-100 dark:hover:bg-gray-800/80 border-gray-200 dark:border-gray-700/50'
}`}
onClick={(e) => handleTrackCheck(playlist.playlist_id, track.track_id, e)}
>
{/* 체크박스 */}
<Checkbox
checked={isTrackChecked}
onClick={(e) => handleTrackCheck(playlist.playlist_id, track.track_id, e)}
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0"
/>
{/* 앨범 이미지 */}
<div className="size-10 sm:size-11 rounded-md overflow-hidden shrink-0 bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
{track.album.image_url ? (
<img
src={track.album.image_url}
alt={track.album.album_title}
className="size-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<Music className={`size-5 ${track.album.image_url ? 'hidden' : ''} text-gray-400 dark:text-gray-500`} />
</div>
{/* 트랙 정보 */}
<div className="flex-1 min-w-0">
<p className="truncate text-xs sm:text-sm font-medium text-foreground">
{track.track_title}
</p>
<p className="truncate text-xs text-muted-foreground">
{track.artists.map(a => a.artist_name).join(', ')}
</p>
</div>
{/* 재생 시간 */}
<div className="text-xs text-muted-foreground shrink-0 bg-gray-100 dark:bg-gray-800 px-2 py-1.5 rounded-md font-medium ">
{track.play_time}
</div>
</motion.div>
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Step 3: 대상 스트리밍 서비스 선택 */}
<AnimatePresence>
{currentStep === TransferStep.SELECT_TARGET_SERVICE && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<Card className="shadow-lg">
<CardHeader className="p-4 sm:p-6">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<CardTitle className="text-lg sm:text-xl mb-2"> </CardTitle>
<CardDescription className="text-xs sm:text-sm">
{selectedTrackIds.size}
</CardDescription>
</div>
<Button
onClick={() => {
setCurrentStep(TransferStep.SELECT_TRACKS_TREE);
}}
variant="outline"
className="h-9 sm:h-10 text-xs sm:text-sm shrink-0"
>
<ArrowLeft className="size-3.5 sm:size-4 mr-1.5" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</Button>
</div>
</CardHeader>
<CardContent className="p-4 sm:p-6">
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6">
{availableServices.map((service, index) => (
<ServiceCard
key={service.name}
name={service.name}
icon={service.icon || ""}
color={service.color}
imageUrl={service.imageUrl}
onClick={() => handleServiceClick(service.name)}
index={index}
/>
))}
</div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Step 4: Spotify 플레이리스트 선택 */}
<AnimatePresence>
{currentStep === TransferStep.SELECT_SPOTIFY_PLAYLIST && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<Card className="shadow-lg">
<CardHeader className="p-4 sm:p-6">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<CardTitle className="text-lg sm:text-xl mb-2 flex items-center gap-2">
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{selectedTrackIds.size} Spotify
</CardDescription>
</div>
<Button
onClick={() => {
setCurrentStep(TransferStep.SELECT_TARGET_SERVICE);
}}
variant="outline"
className="h-9 sm:h-10 text-xs sm:text-sm shrink-0"
>
<ArrowLeft className="size-3.5 sm:size-4 mr-1.5" />
<span className="hidden sm:inline"> </span>
</Button>
</div>
</CardHeader>
<CardContent className="p-4 sm:p-6">
<SpotifyPlaylistSelector
playlists={spotifyPlaylists}
isLoading={isLoadingSpotifyPlaylists}
isProcessing={isAddingTracks}
onPlaylistSelect={handleSpotifyPlaylistSelect}
onCreateNew={handleSpotifyCreateNew}
/>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</main>
</div>
);
}