초기 커밋
바이브 > 스포티파이
This commit is contained in:
853
src/pages/VibePage.tsx
Normal file
853
src/pages/VibePage.tsx
Normal file
@@ -0,0 +1,853 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user