329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
"use client";
|
|
import { useEffect, useState } from "react";
|
|
import { useSession } from "next-auth/react";
|
|
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } from "lucide-react";
|
|
import { ViewMode } from "@/app/page";
|
|
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
|
import { useToast } from "@/components/ToastProvider";
|
|
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
|
|
|
interface MainContentProps {
|
|
viewMode: ViewMode;
|
|
setViewMode: (mode: ViewMode) => void;
|
|
selectedServer: DiscordServer | null;
|
|
setSelectedServer: (server: DiscordServer | null) => void;
|
|
searchQuery: string;
|
|
setSearchQuery: (query: string) => void;
|
|
onSelectServer: (server: DiscordServer) => void;
|
|
}
|
|
|
|
export default function MainContent({
|
|
viewMode,
|
|
setViewMode,
|
|
selectedServer,
|
|
setSelectedServer,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
onSelectServer,
|
|
}: MainContentProps) {
|
|
const { data: session, status } = useSession();
|
|
|
|
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
|
|
const { showToast } = useToast();
|
|
|
|
const [servers, setServers] = useState<DiscordServer[]>([]);
|
|
const [isFetching, setIsFetching] = useState(false);
|
|
|
|
const [searchResults, setSearchResults] = useState<SearchResults>({
|
|
spotify: [],
|
|
youtubeMusic: [],
|
|
youtubeVideo: []
|
|
});
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
|
|
if (!selectedServer) {
|
|
// 🌟 alert -> showToast 교체
|
|
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
|
return;
|
|
}
|
|
|
|
const userId = session?.user?.id;
|
|
if (!userId) {
|
|
showToast("로그인이 필요합니다.", "error");
|
|
return;
|
|
}
|
|
|
|
let endpoint = "";
|
|
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
|
|
if (actionType === 'player_play') {
|
|
endpoint = "/api/player/play";
|
|
bodyData.track = track;
|
|
} else if (actionType === 'player_playlist') {
|
|
endpoint = "/api/player/playlist";
|
|
bodyData.playlistUrl = playlistUrl;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(bodyData),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (res.ok && data.success) {
|
|
// 🌟 성공 시 초록색 알림
|
|
showToast("재생목록에 곡을 추가했습니다!", "success");
|
|
} else {
|
|
// 🌟 에러 시 빨간색 알림
|
|
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
|
}
|
|
} catch (error) {
|
|
console.error("Music Action Error:", error);
|
|
// 🌟 네트워크 에러 시 알림
|
|
showToast('서버와 통신할 수 없습니다.', "error");
|
|
}
|
|
};
|
|
|
|
const getPermissionLabel = (server: DiscordServer | null) => {
|
|
if (!server) return "알 수 없음";
|
|
if (server.owner) return "👑 서버 주인";
|
|
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
|
|
const raw: unknown = server.permissions;
|
|
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버";
|
|
try {
|
|
const perms = BigInt(raw);
|
|
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자";
|
|
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저";
|
|
return "👤 일반 멤버";
|
|
} catch {
|
|
return "👤 일반 멤버";
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (status === "authenticated") {
|
|
setIsFetching(true);
|
|
const cached = sessionStorage.getItem("filtered_servers");
|
|
if (cached) {
|
|
try {
|
|
const parsed = JSON.parse(cached);
|
|
if (Array.isArray(parsed)) {
|
|
setServers(parsed);
|
|
setIsFetching(false);
|
|
}
|
|
} catch {
|
|
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
|
|
sessionStorage.removeItem("filtered_servers");
|
|
}
|
|
}
|
|
|
|
fetch("/api/servers")
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
if (Array.isArray(data)) {
|
|
setServers(data);
|
|
sessionStorage.setItem("filtered_servers", JSON.stringify(data));
|
|
}
|
|
})
|
|
.finally(() => setIsFetching(false));
|
|
}
|
|
}, [status]);
|
|
|
|
useEffect(() => {
|
|
if (viewMode === "SEARCH_RESULT" && searchQuery) {
|
|
setIsSearching(true);
|
|
setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] });
|
|
|
|
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
|
.then(async (res) => {
|
|
const data = await res.json();
|
|
if (res.ok && data) {
|
|
setSearchResults({
|
|
spotify: data.spotify || [],
|
|
youtubeMusic: data.youtubeMusic || [],
|
|
youtubeVideo: data.youtubeVideo || []
|
|
});
|
|
} else {
|
|
console.error("Search Error:", data);
|
|
}
|
|
})
|
|
.catch(err => console.error("Fetch Error:", err))
|
|
.finally(() => setIsSearching(false));
|
|
}
|
|
}, [viewMode, searchQuery]);
|
|
|
|
if (status === "unauthenticated") {
|
|
return (
|
|
<main className="flex-1 flex flex-col items-center justify-center bg-neutral-900">
|
|
<Server size={48} className="text-neutral-500 mb-4" />
|
|
<h2 className="text-2xl font-bold">로그인이 필요합니다</h2>
|
|
<p className="text-neutral-400">서비스를 이용하시려면 디스코드 로그인을 해주세요.</p>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
|
|
|
|
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
|
|
|
const renderTrackCard = (track: SearchTrack) => (
|
|
<div key={track.videoId || track.id || track.url || track.title} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
|
|
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
|
|
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleMusicAction('player_play', track);
|
|
}}
|
|
className="absolute right-2 bottom-2 cursor-pointer bg-green-500 hover:bg-green-400 rounded-full p-2 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl"
|
|
title="바로 재생"
|
|
>
|
|
<Play fill="black" stroke="black" size={16} />
|
|
</button>
|
|
</div>
|
|
<h3 className="font-medium text-white text-sm truncate">{track.title}</h3>
|
|
<p className="text-xs text-neutral-400 truncate">{track.artist}</p>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
|
|
|
|
{/* 화면 1: 서버 목록 */}
|
|
{viewMode === "SERVER_LIST" && (
|
|
<>
|
|
<h2 className="text-2xl font-bold mb-6">접속 중인 서버 선택</h2>
|
|
{isFetching && servers.length === 0 ? (
|
|
<div className="flex flex-col items-center py-20">
|
|
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
|
<p>서버 목록을 불러오는 중...</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
{servers.map((server) => (
|
|
<div
|
|
key={server.id}
|
|
onClick={() => onSelectServer(server)}
|
|
className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md w-full"
|
|
>
|
|
<div className="aspect-square bg-neutral-700 rounded-full mb-2 overflow-hidden shadow-lg flex items-center justify-center">
|
|
{server.icon ? (
|
|
<img src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} className="w-full h-full object-cover" alt="" />
|
|
) : (
|
|
<Server size={24} className="text-neutral-500" />
|
|
)}
|
|
</div>
|
|
<h3 className="font-medium text-center text-sm truncate">{server.name}</h3>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 화면 2: 서버 상세 대시보드 */}
|
|
{viewMode === "SERVER_DETAIL" && selectedServer && (
|
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<button onClick={() => {
|
|
setViewMode("SERVER_LIST");
|
|
setSelectedServer(null);
|
|
setSearchQuery("");
|
|
}} className="flex items-center gap-1 text-neutral-400 hover:text-white mb-6 cursor-pointer group">
|
|
<ChevronLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
|
|
서버 목록으로 돌아가기
|
|
</button>
|
|
|
|
<div className="flex items-end gap-6 mb-8">
|
|
<div className="w-48 h-48 bg-neutral-800 rounded-2xl shadow-2xl flex items-center justify-center overflow-hidden">
|
|
{selectedServer.icon ? (
|
|
<img src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`} className="w-full h-full object-cover" alt="" />
|
|
) : <Server size={64} className="text-neutral-500" />}
|
|
</div>
|
|
<div>
|
|
<span className="text-sm font-bold uppercase text-neutral-400 tracking-wider">서버 대시보드</span>
|
|
<h1 className="text-5xl font-black mt-2 mb-4">{selectedServer.name}</h1>
|
|
<p className="text-neutral-400">이 서버에서 음악 봇이 활발하게 작동 중입니다.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-black/20 rounded-xl p-6 border border-neutral-800">
|
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Music size={20} /> 상세 정보</h3>
|
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
|
|
<p className="text-neutral-500 mb-1">서버 고유 ID</p>
|
|
<p className="font-mono text-white">{selectedServer.id}</p>
|
|
</div>
|
|
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
|
|
<p className="text-neutral-500 mb-1">나의 서버 권한</p>
|
|
<p className="text-green-400 font-bold">{getPermissionLabel(selectedServer)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
|
|
{viewMode === "SEARCH_RESULT" && (
|
|
<div>
|
|
<h2 className="text-2xl font-bold mb-2">“{searchQuery}” 검색 결과</h2>
|
|
<p className="text-neutral-400 mb-8 text-sm">입력하신 검색어에 대한 플랫폼별 결과입니다.</p>
|
|
|
|
{isSearching ? (
|
|
<div className="flex flex-col items-center py-20">
|
|
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
|
<p>노래를 찾는 중입니다...</p>
|
|
</div>
|
|
) : !hasAnyResults ? (
|
|
<div className="flex flex-col items-center py-20 text-neutral-500">
|
|
<SearchX size={48} className="mb-4" />
|
|
<p>검색 결과가 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-10 pb-10">
|
|
|
|
{/* 카테고리 1: Spotify */}
|
|
{searchResults.spotify.length > 0 && (
|
|
<section>
|
|
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400">
|
|
<Disc size={24} /> Spotify 결과
|
|
</h3>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
{searchResults.spotify.map(renderTrackCard)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* 카테고리 2: YouTube Music */}
|
|
{searchResults.youtubeMusic.length > 0 && (
|
|
<section>
|
|
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500">
|
|
<Music size={24} /> YouTube Music
|
|
</h3>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
{searchResults.youtubeMusic.map(renderTrackCard)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* 카테고리 3: YouTube Video */}
|
|
{searchResults.youtubeVideo.length > 0 && (
|
|
<section>
|
|
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white">
|
|
<MonitorPlay size={24} className="text-red-600" /> YouTube 영상
|
|
</h3>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
|
{searchResults.youtubeVideo.map(renderTrackCard)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
} |