854 lines
38 KiB
TypeScript
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>
|
|
);
|
|
}
|