지금까지 내용 커밋
This commit is contained in:
7
page/src/components/Providers.tsx
Normal file
7
page/src/components/Providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
25
page/src/components/layout/LeftSidebar.tsx
Normal file
25
page/src/components/layout/LeftSidebar.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
import { ListMusic, Library } from "lucide-react";
|
||||
|
||||
export default function LeftSidebar() {
|
||||
return (
|
||||
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
|
||||
<nav className="flex flex-col gap-4 text-neutral-400 font-medium">
|
||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
||||
<Library size={20} /> 내 플레이리스트
|
||||
</button>
|
||||
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
|
||||
<ListMusic size={20} /> 좋아요 표시한 곡
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<hr className="border-neutral-800" />
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm text-neutral-400 overflow-y-auto">
|
||||
<p className="hover:text-white cursor-pointer truncate">출근길 노동요 모음</p>
|
||||
<p className="hover:text-white cursor-pointer truncate">2024 빌보드 탑 100</p>
|
||||
<p className="hover:text-white cursor-pointer truncate">비 오는 날 듣기 좋은 재즈</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
94
page/src/components/layout/TopNav.tsx
Normal file
94
page/src/components/layout/TopNav.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface TopNavProps {
|
||||
onSearch: (query: string) => void;
|
||||
onHome: () => void;
|
||||
selectedServer: any; // 🌟 추가: 선택된 서버 정보
|
||||
}
|
||||
|
||||
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const [term, setTerm] = useState("");
|
||||
|
||||
const handleSearchClick = () => {
|
||||
// 🌟 서버 선택 여부 체크
|
||||
if (!selectedServer) {
|
||||
alert("먼저 왼쪽 목록에서 관리할 서버를 선택해주세요!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (term.trim()) {
|
||||
onSearch(term);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && term.trim()) {
|
||||
onSearch(term);
|
||||
}
|
||||
};
|
||||
|
||||
// 🌟 홈 버튼 클릭 시 검색어 초기화 로직 추가
|
||||
const handleHomeClick = () => {
|
||||
setTerm(""); // 검색창 비우기
|
||||
onHome(); // 원래 홈 기능 실행
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="h-16 flex-shrink-0 bg-black border-b border-neutral-800 flex items-center justify-between px-6 z-20">
|
||||
{/* 1. 왼쪽: 로고 */}
|
||||
<div className="flex items-center gap-2 text-xl font-bold text-white w-52">
|
||||
<ListMusic className="text-green-500" />
|
||||
<span>MusicBot</span>
|
||||
</div>
|
||||
|
||||
{/* 2. 가운데: 검색창 & 홈 버튼 */}
|
||||
<div className="flex-1 max-w-2xl px-4 flex items-center gap-4">
|
||||
<button
|
||||
onClick={handleHomeClick} // 🌟 수정된 핸들러 연결
|
||||
className="p-2 hover:bg-neutral-800 rounded-full transition-colors cursor-pointer text-neutral-400 hover:text-white"
|
||||
title="홈으로"
|
||||
>
|
||||
<Home size={22} />
|
||||
</button>
|
||||
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!selectedServer} // 🌟 서버 미선택 시 입력창 비활성화 (선택 사항)
|
||||
placeholder={selectedServer ? "노래 검색 후 엔터를 누르세요" : "서버를 먼저 선택해주세요"}
|
||||
className={`w-full h-10 pl-10 pr-4 rounded-full bg-neutral-800 border border-neutral-700 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white/30 transition-all ${!selectedServer ? 'opacity-50 cursor-not-allowed' : 'opacity-100'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 오른쪽: 유저 프로필 영역 (기존과 동일) */}
|
||||
<div className="flex items-center justify-end w-auto min-w-[13rem]">
|
||||
{status === "loading" ? (
|
||||
<div className="text-sm text-neutral-400">로딩 중...</div>
|
||||
) : status === "authenticated" && session?.user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 bg-neutral-800/80 pr-4 pl-1 py-1 rounded-full border border-neutral-700 shadow-sm cursor-default">
|
||||
<img src={session.user.image || ""} alt="Profile" className="w-7 h-7 rounded-full" />
|
||||
<span className="text-sm font-medium text-white whitespace-nowrap">{session.user.name}</span>
|
||||
</div>
|
||||
<button onClick={() => signOut()} className="cursor-pointer text-neutral-400 hover:text-red-400 p-2 rounded-full hover:bg-neutral-800 transition-colors">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => signIn("discord")} className="cursor-pointer flex items-center gap-2 bg-[#5865F2] hover:bg-[#4752C4] text-white px-5 py-2 rounded-full font-medium transition-colors text-sm shadow-md">
|
||||
<LogIn size={16} /> Discord 로그인
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
212
page/src/components/player/MainContent.tsx
Normal file
212
page/src/components/player/MainContent.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Play, ChevronLeft, Server, Music, Loader2, SearchX } from "lucide-react";
|
||||
import { ViewMode } from "@/app/page";
|
||||
|
||||
interface MainContentProps {
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
selectedServer: any;
|
||||
setSelectedServer: (server: any) => void;
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
onSelectServer: (server: any) => void;
|
||||
}
|
||||
|
||||
export default function MainContent({
|
||||
viewMode,
|
||||
setViewMode,
|
||||
selectedServer,
|
||||
setSelectedServer,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
onSelectServer,
|
||||
}: MainContentProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const [servers, setServers] = useState<any[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// 권한 계산 함수
|
||||
const getPermissionLabel = (server: any) => {
|
||||
if (!server) return "알 수 없음";
|
||||
if (server.owner) return "👑 서버 주인";
|
||||
try {
|
||||
const perms = BigInt(server.permissions);
|
||||
if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자";
|
||||
if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저";
|
||||
return "👤 일반 멤버";
|
||||
} catch {
|
||||
return "👤 일반 멤버";
|
||||
}
|
||||
};
|
||||
|
||||
// 1. 서버 목록 불러오기
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") {
|
||||
setIsFetching(true);
|
||||
const cached = sessionStorage.getItem("filtered_servers");
|
||||
if (cached) {
|
||||
setServers(JSON.parse(cached));
|
||||
setIsFetching(false);
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
// 2. 검색 결과 불러오기
|
||||
useEffect(() => {
|
||||
if (viewMode === "SEARCH_RESULT" && searchQuery) {
|
||||
setIsSearching(true);
|
||||
setSearchResults([]);
|
||||
|
||||
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
||||
.then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (res.ok && Array.isArray(data)) {
|
||||
setSearchResults(data);
|
||||
} 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" />;
|
||||
|
||||
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 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => onSelectServer(server)}
|
||||
className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md"
|
||||
>
|
||||
<div className="aspect-square bg-neutral-700 rounded-full mb-4 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={32} className="text-neutral-500" />}
|
||||
</div>
|
||||
<h3 className="font-semibold text-center 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: 검색 결과 목록 */}
|
||||
{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>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-20 text-neutral-500">
|
||||
<SearchX size={48} className="mb-4" />
|
||||
<p>검색 결과가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||
{searchResults.map((track) => (
|
||||
<div key={track.videoId} className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md">
|
||||
<div className="aspect-square bg-neutral-700 rounded-md mb-4 relative overflow-hidden shadow-lg">
|
||||
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
||||
<div className="absolute right-2 bottom-2 bg-green-500 rounded-full p-3 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl">
|
||||
<Play fill="black" stroke="black" size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-white truncate">{track.title}</h3>
|
||||
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
47
page/src/components/player/PlayerBar.tsx
Normal file
47
page/src/components/player/PlayerBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
import { SkipForward, SkipBack, Volume2, Pause } from "lucide-react";
|
||||
|
||||
export default function PlayerBar() {
|
||||
return (
|
||||
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
|
||||
<div className="flex items-center gap-4 w-1/4">
|
||||
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md"></div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-white hover:underline cursor-pointer">내 손을 잡아</span>
|
||||
<span className="text-xs text-neutral-400 hover:underline cursor-pointer">아이유(IU)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
|
||||
<div className="flex items-center gap-6">
|
||||
<button className="text-neutral-400 hover:text-white transition-colors">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
<button className="w-8 h-8 rounded-full bg-white flex items-center justify-center hover:scale-105 transition-transform text-black">
|
||||
<Pause size={18} fill="black" />
|
||||
</button>
|
||||
<button className="text-neutral-400 hover:text-white transition-colors">
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
|
||||
<span>01:12</span>
|
||||
<div className="h-1 bg-neutral-600 rounded-full flex-1 cursor-pointer group">
|
||||
<div className="h-full bg-white rounded-full w-1/3 group-hover:bg-green-500 transition-colors relative">
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span>03:16</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
|
||||
<Volume2 size={20} />
|
||||
<div className="w-24 h-1 bg-neutral-600 rounded-full cursor-pointer group">
|
||||
<div className="h-full bg-white rounded-full w-2/3 group-hover:bg-green-500 transition-colors"></div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
24
page/src/components/player/QueueSidebar.tsx
Normal file
24
page/src/components/player/QueueSidebar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
export default function QueueSidebar() {
|
||||
return (
|
||||
<aside className="w-80 bg-black p-6 border-l border-neutral-800 flex flex-col">
|
||||
<h2 className="text-lg font-bold mb-6">현재 재생 목록</h2>
|
||||
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||
{[1, 2, 3, 4, 5].map((item) => (
|
||||
<div key={item} className="flex items-center gap-3 group cursor-pointer">
|
||||
<div className="w-10 h-10 bg-neutral-800 rounded flex-shrink-0"></div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
|
||||
{item === 1 ? "Hype Boy" : "Supernova"}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400 truncate">
|
||||
{item === 1 ? "NewJeans" : "aespa"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user