Compare commits
3 Commits
experiment
...
4021862c51
| Author | SHA1 | Date | |
|---|---|---|---|
| 4021862c51 | |||
| 5f8dc184b4 | |||
| 1021a61ead |
@@ -116,10 +116,22 @@ export class LavalinkManager {
|
||||
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
|
||||
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
|
||||
// ('-p' 플래그를 붙이면 스포티파이 우선 검색)
|
||||
// 기본 경로는 YTM 검색과 ytsearch 폴백을 '동시에' 출발시킨다.
|
||||
// YTM 적중률이 약 60%라, 빗나가는 40%에서는 YTM(~0.5초)을 끝까지 기다린 뒤 다시
|
||||
// ytsearch(~0.5초)를 도는 순차 대기가 통째로 낭비된다. 미리 같이 돌려두면 빗나가도
|
||||
// 추가 대기가 없다. (적중 시엔 폴백 결과를 버리므로 Lavalink 검색 1회가 헛돌지만 비용이 작다.)
|
||||
let searchText: string;
|
||||
let pendingFallback: ReturnType<typeof node.rest.resolve> | null = null;
|
||||
if (isUrl) searchText = text;
|
||||
else if (flags.has('p')) searchText = (await Spotify.getSearchUrl(text)) ?? (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
|
||||
else searchText = (await YoutubeMusic.getSearchUrl(text)) ?? `ytsearch:${text}`;
|
||||
else {
|
||||
const fallbackText = `ytsearch:${text}` + (flags.has('o') ? "" : " Topic");
|
||||
pendingFallback = node.rest.resolve(fallbackText);
|
||||
pendingFallback.catch(() => {}); // 적중해서 버려질 때 unhandledRejection 방지
|
||||
const ytm = await YoutubeMusic.getSearchUrl(text).catch(() => null);
|
||||
searchText = ytm ?? fallbackText;
|
||||
if (ytm) pendingFallback = null; // YTM 적중 → 폴백 결과는 사용하지 않음
|
||||
}
|
||||
// 스포티파이 트랙 URL은 Lavalink가 유튜브 '영상(뮤비)'으로 폴백한다(유튜브뮤직 연결돼 있어도).
|
||||
// 대시보드에서 고른 스포티파이 곡이나 `-p` 결과가 트랙 URL이면, 메타데이터(제목+아티스트)로
|
||||
// 유튜브뮤직 '오디오'를 직접 찾아 대체한다(텍스트 검색 경로와 동일하게 원곡 오디오 우선).
|
||||
@@ -131,8 +143,9 @@ export class LavalinkManager {
|
||||
searchText = (await YoutubeMusic.getSearchUrl(q)) ?? `ytsearch:${q}`;
|
||||
}
|
||||
}
|
||||
if (searchText.startsWith("ytsearch") && !flags.has('o')) searchText += " Topic";
|
||||
const result = await node.rest.resolve(searchText);
|
||||
if (searchText.startsWith("ytsearch") && !flags.has('o') && !searchText.endsWith(" Topic")) searchText += " Topic";
|
||||
// 위에서 미리 띄워둔 폴백 검색이 있으면 그 결과를 그대로 쓴다(중복 요청 방지).
|
||||
const result = pendingFallback ? await pendingFallback : await node.rest.resolve(searchText);
|
||||
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
|
||||
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
|
||||
return null;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { ListMusic, X } from "lucide-react";
|
||||
import TopNav from "@/components/layout/TopNav";
|
||||
import LeftSidebar from "@/components/layout/LeftSidebar";
|
||||
import MainContent from "@/components/player/MainContent";
|
||||
@@ -12,11 +14,17 @@ import type { DiscordServer } from "@/types/music";
|
||||
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
|
||||
|
||||
export default function MusicPlayerLayout() {
|
||||
const { status } = useSession();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
||||
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// 모바일에서 대기열은 상시 컬럼이 아니라 오른쪽에서 밀려나오는 패널로 띄운다.
|
||||
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
||||
|
||||
const isAuthed = status === "authenticated";
|
||||
// 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다.
|
||||
const showPlayback = isAuthed && !!selectedServer;
|
||||
|
||||
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
|
||||
const handleHome = () => {
|
||||
if (selectedServer) {
|
||||
setViewMode("SERVER_DETAIL");
|
||||
@@ -27,28 +35,37 @@ export default function MusicPlayerLayout() {
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 실행 시
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setViewMode("SEARCH_RESULT");
|
||||
};
|
||||
|
||||
// 서버 선택 시
|
||||
const handleSelectServer = (server: DiscordServer) => {
|
||||
setSelectedServer(server);
|
||||
setViewMode("SERVER_DETAIL");
|
||||
};
|
||||
|
||||
const handleBackToServers = () => {
|
||||
setViewMode("SERVER_LIST");
|
||||
setSelectedServer(null);
|
||||
setSearchQuery("");
|
||||
setIsQueueOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-neutral-900 text-white overflow-hidden font-sans">
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-neutral-950 font-sans text-white">
|
||||
<TopNav
|
||||
onSearch={handleSearch}
|
||||
onHome={handleHome}
|
||||
selectedServer={selectedServer} // 🌟 이 줄을 추가해서 선택된 서버 정보를 넘깁니다.
|
||||
selectedServer={selectedServer}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<LeftSidebar />
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{/* 좌측 사이드바: 보조 정보라 넓은 화면에서만 노출한다(모바일 공간 절약). */}
|
||||
{showPlayback && (
|
||||
<LeftSidebar selectedServer={selectedServer} onBackToServers={handleBackToServers} />
|
||||
)}
|
||||
|
||||
<MainContent
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
@@ -58,10 +75,66 @@ export default function MusicPlayerLayout() {
|
||||
setSearchQuery={setSearchQuery}
|
||||
onSelectServer={handleSelectServer}
|
||||
/>
|
||||
<QueueSidebar selectedServer={selectedServer} />
|
||||
|
||||
{showPlayback && (
|
||||
<>
|
||||
{/* 모바일에서 대기열이 열렸을 때 뒤쪽을 덮는 배경(탭하면 닫힘) */}
|
||||
{isQueueOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="대기열 닫기"
|
||||
onClick={() => setIsQueueOpen(false)}
|
||||
className="absolute inset-0 z-30 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 대기열: 데스크톱은 고정 컬럼, 모바일은 슬라이드 패널 (인스턴스는 하나만 유지) */}
|
||||
<div
|
||||
className={`absolute inset-y-0 right-0 z-40 w-[22rem] max-w-[88%] transform border-l border-neutral-800 bg-neutral-950 shadow-2xl transition-transform duration-300 ease-out
|
||||
${isQueueOpen ? "translate-x-0" : "translate-x-full"}
|
||||
lg:static lg:z-auto lg:w-80 lg:max-w-none lg:translate-x-0 lg:shadow-none`}
|
||||
aria-hidden={!isQueueOpen && typeof window !== "undefined" && window.innerWidth < 1024}
|
||||
>
|
||||
<QueueSidebar
|
||||
selectedServer={selectedServer}
|
||||
onClose={() => setIsQueueOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlayerBar selectedServer={selectedServer} />
|
||||
{showPlayback && (
|
||||
<PlayerBar
|
||||
selectedServer={selectedServer}
|
||||
onToggleQueue={() => setIsQueueOpen((v) => !v)}
|
||||
isQueueOpen={isQueueOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 모바일 전용 대기열 열기 버튼: 플레이어바에도 있지만, 재생 전에도 접근할 수 있게 둔다. */}
|
||||
{showPlayback && !isQueueOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsQueueOpen(true)}
|
||||
className="fixed bottom-24 right-4 z-30 flex h-14 w-14 items-center justify-center rounded-full bg-green-500 text-black shadow-xl transition hover:bg-green-400 active:scale-95 lg:hidden"
|
||||
aria-label="대기열 열기"
|
||||
>
|
||||
<ListMusic size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 모바일 패널 닫기 버튼(패널 위에 떠 있음) */}
|
||||
{showPlayback && isQueueOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsQueueOpen(false)}
|
||||
className="fixed right-4 top-20 z-50 flex h-11 w-11 items-center justify-center rounded-full bg-neutral-800 text-white shadow-lg transition hover:bg-neutral-700 lg:hidden"
|
||||
aria-label="대기열 닫기"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,61 @@
|
||||
"use client";
|
||||
import { ListMusic } from "lucide-react";
|
||||
import { ChevronLeft, Info, Server } from "lucide-react";
|
||||
import type { DiscordServer } from "@/types/music";
|
||||
|
||||
export default function LeftSidebar() {
|
||||
interface LeftSidebarProps {
|
||||
selectedServer: DiscordServer | null;
|
||||
onBackToServers: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 좌측 보조 패널. 예전에는 안내 문구만 있는 죽은 공간이었어서,
|
||||
* "지금 어느 서버를 조작 중인지"와 서버 전환 동선을 넣어 실제로 쓰이게 했다.
|
||||
* 좁은 화면에서는 숨긴다(같은 정보가 본문/플레이어에 이미 있다).
|
||||
*/
|
||||
export default function LeftSidebar({ selectedServer, onBackToServers }: LeftSidebarProps) {
|
||||
return (
|
||||
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
|
||||
<div className="flex items-center gap-2 text-neutral-400 font-medium">
|
||||
<ListMusic size={20} />
|
||||
<span>음악 봇 컨트롤</span>
|
||||
<aside className="hidden w-60 flex-shrink-0 flex-col gap-4 border-r border-neutral-800 bg-neutral-950 p-4 lg:flex">
|
||||
<p className="px-1 text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
||||
조작 중인 서버
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 rounded-xl border border-neutral-800 bg-neutral-900 p-3">
|
||||
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-neutral-800">
|
||||
{selectedServer?.icon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Server size={20} className="text-neutral-500" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{selectedServer?.name ?? "선택 안 됨"}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500">음악 봇 연결됨</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr className="border-neutral-800" />
|
||||
<button
|
||||
onClick={onBackToServers}
|
||||
className="flex h-11 items-center gap-2 rounded-lg px-3 text-sm text-neutral-300 transition-colors hover:bg-neutral-900 hover:text-white"
|
||||
>
|
||||
<ChevronLeft size={18} aria-hidden />
|
||||
다른 서버 선택
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||
상단 검색창에서 노래를 찾아 우측 대기열에 추가할 수 있습니다.
|
||||
디스코드 음성 채널에 입장한 상태에서 사용해주세요.
|
||||
</p>
|
||||
<div className="mt-auto rounded-xl border border-neutral-800 bg-neutral-900/60 p-3">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-semibold text-neutral-300">
|
||||
<Info size={14} aria-hidden /> 사용 방법
|
||||
</p>
|
||||
<p className="text-xs leading-relaxed text-neutral-500">
|
||||
디스코드 음성 채널에 먼저 들어간 뒤, 위 검색창에서 노래를 찾아 재생 버튼을 누르세요.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,77 +7,133 @@ import type { DiscordServer } from "@/types/music";
|
||||
interface TopNavProps {
|
||||
onSearch: (query: string) => void;
|
||||
onHome: () => void;
|
||||
selectedServer: DiscordServer | null; // 🌟 추가: 선택된 서버 정보
|
||||
selectedServer: DiscordServer | null;
|
||||
}
|
||||
|
||||
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */
|
||||
function Avatar({
|
||||
user,
|
||||
size,
|
||||
}: {
|
||||
user: { name?: string | null; image?: string | null };
|
||||
size: "sm" | "md";
|
||||
}) {
|
||||
const box = size === "sm" ? "h-7 w-7 text-xs" : "h-9 w-9 text-sm";
|
||||
if (user.image) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={user.image} alt="" className={`${box} rounded-full object-cover`} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`${box} flex items-center justify-center rounded-full bg-neutral-700 font-semibold text-white`}
|
||||
aria-hidden
|
||||
>
|
||||
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const [term, setTerm] = useState("");
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && term.trim()) {
|
||||
onSearch(term);
|
||||
}
|
||||
const isAuthed = status === "authenticated";
|
||||
const canSearch = isAuthed && !!selectedServer;
|
||||
|
||||
const submitSearch = () => {
|
||||
if (canSearch && term.trim()) onSearch(term.trim());
|
||||
};
|
||||
|
||||
// 🌟 홈 버튼 클릭 시 검색어 초기화 로직 추가
|
||||
const handleHomeClick = () => {
|
||||
setTerm(""); // 검색창 비우기
|
||||
onHome(); // 원래 홈 기능 실행
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") submitSearch();
|
||||
};
|
||||
|
||||
const handleHomeClick = () => {
|
||||
setTerm("");
|
||||
onHome();
|
||||
};
|
||||
|
||||
// 상황별로 검색창 안내를 다르게 준다(로그인 전인데 "서버를 고르라"고 하면 혼란스럽다).
|
||||
const searchPlaceholder = !isAuthed
|
||||
? "로그인하면 검색할 수 있어요"
|
||||
: !selectedServer
|
||||
? "먼저 서버를 선택해주세요"
|
||||
: "노래 제목이나 유튜브·스포티파이 주소";
|
||||
|
||||
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>
|
||||
<header className="z-20 flex-shrink-0 border-b border-neutral-800 bg-neutral-950">
|
||||
<nav className="flex h-16 items-center gap-2 px-3 sm:gap-4 sm:px-6">
|
||||
{/* 로고: 좁은 화면에서는 아이콘만 남겨 검색창 폭을 확보한다. */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2 text-lg font-bold">
|
||||
<ListMusic className="text-green-500" aria-hidden />
|
||||
<span className="hidden sm:inline">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>
|
||||
{isAuthed && (
|
||||
<button
|
||||
onClick={handleHomeClick}
|
||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-white"
|
||||
aria-label="처음 화면으로"
|
||||
>
|
||||
<Home size={20} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" size={18} />
|
||||
{/* 검색: 남는 폭을 모두 차지하게 해서 모바일에서도 쓸 만하게 만든다. */}
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500"
|
||||
size={18}
|
||||
aria-hidden
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
type="search"
|
||||
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'}`}
|
||||
disabled={!canSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label="노래 검색"
|
||||
className="h-11 w-full rounded-full border border-neutral-700 bg-neutral-900 pl-10 pr-4 text-sm text-white placeholder:text-neutral-500 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</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 className="flex flex-shrink-0 items-center">
|
||||
{status === "loading" ? (
|
||||
<div className="h-11 w-11 animate-pulse rounded-full bg-neutral-800" aria-label="로그인 상태 확인 중" />
|
||||
) : isAuthed && session?.user ? (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<span className="hidden items-center gap-2 rounded-full border border-neutral-700 bg-neutral-900 py-1 pl-1 pr-3 md:flex">
|
||||
<Avatar user={session.user} size="sm" />
|
||||
<span className="max-w-[10rem] truncate text-sm font-medium">{session.user.name}</span>
|
||||
</span>
|
||||
<span className="md:hidden">
|
||||
<Avatar user={session.user} size="md" />
|
||||
</span>
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-red-400"
|
||||
aria-label="로그아웃"
|
||||
title="로그아웃"
|
||||
>
|
||||
<LogOut size={18} aria-hidden />
|
||||
</button>
|
||||
</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
|
||||
onClick={() => signIn("discord")}
|
||||
className="flex h-11 items-center gap-2 rounded-full bg-[#5865F2] px-4 text-sm font-medium text-white shadow-md transition-colors hover:bg-[#4752C4] sm:px-5"
|
||||
>
|
||||
<LogIn size={16} aria-hidden />
|
||||
<span className="hidden sm:inline">Discord 로그인</span>
|
||||
<span className="sm:hidden">로그인</span>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"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 { signIn, useSession } from "next-auth/react";
|
||||
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc, ListMusic, LogIn } from "lucide-react";
|
||||
import { ViewMode } from "@/app/page";
|
||||
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
||||
import { useToast } from "@/components/ToastProvider";
|
||||
@@ -157,67 +157,108 @@ export default function MainContent({
|
||||
|
||||
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 className="flex flex-1 flex-col items-center justify-center gap-5 bg-neutral-900 px-6 text-center">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-neutral-800">
|
||||
<ListMusic size={32} className="text-green-500" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">디스코드로 시작하기</h2>
|
||||
<p className="mt-2 max-w-sm text-sm leading-relaxed text-neutral-400">
|
||||
로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고,
|
||||
브라우저에서 바로 노래를 검색·재생할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
{/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */}
|
||||
<button
|
||||
onClick={() => signIn("discord")}
|
||||
className="flex h-12 items-center gap-2 rounded-full bg-[#5865F2] px-6 font-medium text-white shadow-lg transition hover:bg-[#4752C4] active:scale-95"
|
||||
>
|
||||
<LogIn size={18} aria-hidden />
|
||||
Discord 로그인
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center bg-neutral-900">
|
||||
<Loader2 className="animate-spin text-neutral-600" size={32} aria-label="불러오는 중" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||
|
||||
// 카드 전체가 눌리는 버튼이다. 예전에는 hover 시에만 나타나는 작은 아이콘만 눌러야 해서
|
||||
// 터치 기기(hover 없음)에서는 재생 방법을 찾기 어려웠다.
|
||||
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>
|
||||
<button
|
||||
key={track.videoId || track.id || track.url || track.title}
|
||||
type="button"
|
||||
onClick={() => handleMusicAction('player_play', track)}
|
||||
aria-label={`${track.title} 재생목록에 추가`}
|
||||
className="group w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 text-left shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]"
|
||||
>
|
||||
<span className="relative mb-2 block aspect-square overflow-hidden rounded-md bg-neutral-700 shadow-lg">
|
||||
{track.thumbnail && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={track.thumbnail} className="h-full w-full object-cover" alt="" />
|
||||
)}
|
||||
{/* 터치 기기에서는 항상 보이고, 마우스 환경에서는 hover 시 강조된다. */}
|
||||
<span className="absolute bottom-2 right-2 flex h-11 w-11 items-center justify-center rounded-full bg-green-500 shadow-xl transition-all group-hover:bg-green-400 sm:h-10 sm:w-10 sm:opacity-90 sm:group-hover:opacity-100">
|
||||
<Play fill="black" stroke="black" size={18} aria-hidden />
|
||||
</span>
|
||||
</span>
|
||||
<span className="block truncate text-sm font-medium text-white">{track.title}</span>
|
||||
<span className="block truncate text-xs text-neutral-400">{track.artist}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
|
||||
<main className="flex flex-1 flex-col overflow-y-auto bg-gradient-to-b from-neutral-800/50 to-neutral-900 p-4 pb-28 sm:p-6 lg:p-8">
|
||||
|
||||
{/* 화면 1: 서버 목록 */}
|
||||
{viewMode === "SERVER_LIST" && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold mb-6">접속 중인 서버 선택</h2>
|
||||
<h2 className="mb-1 text-xl font-bold sm:text-2xl">어느 서버에서 들을까요?</h2>
|
||||
<p className="mb-5 text-sm text-neutral-400">음악 봇이 들어가 있는 서버만 보입니다.</p>
|
||||
{isFetching && servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-20">
|
||||
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-neutral-400">
|
||||
<Loader2 className="animate-spin text-green-500" size={36} aria-hidden />
|
||||
<p>서버 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-20 text-center text-neutral-500">
|
||||
<Server size={40} className="opacity-50" aria-hidden />
|
||||
<p className="text-sm">표시할 서버가 없습니다.</p>
|
||||
<p className="max-w-sm text-xs text-neutral-600">
|
||||
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
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"
|
||||
className="w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]"
|
||||
>
|
||||
<div className="aspect-square bg-neutral-700 rounded-full mb-2 overflow-hidden shadow-lg flex items-center justify-center">
|
||||
<span className="mb-2 flex aspect-square items-center justify-center overflow-hidden rounded-full bg-neutral-700 shadow-lg">
|
||||
{server.icon ? (
|
||||
<img src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} className="w-full h-full object-cover" alt="" />
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`}
|
||||
className="h-full w-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<Server size={24} className="text-neutral-500" />
|
||||
<Server size={24} className="text-neutral-500" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-center text-sm truncate">{server.name}</h3>
|
||||
</div>
|
||||
</span>
|
||||
<span className="block truncate text-center text-sm font-medium">{server.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -236,29 +277,45 @@ export default function MainContent({
|
||||
서버 목록으로 돌아가기
|
||||
</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">
|
||||
<div className="mb-6 flex flex-col items-center gap-4 text-center sm:flex-row sm:items-end sm:gap-6 sm:text-left">
|
||||
<span className="flex h-28 w-28 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800 shadow-2xl sm:h-40 sm:w-40">
|
||||
{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>
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`}
|
||||
className="h-full w-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
) : <Server size={56} className="text-neutral-500" aria-hidden />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-neutral-400">서버 대시보드</span>
|
||||
<h1 className="mt-1 break-words text-2xl font-black sm:text-4xl lg:text-5xl">{selectedServer.name}</h1>
|
||||
<p className="mt-2 text-sm 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 className="mb-6 rounded-xl border border-green-500/20 bg-green-500/10 p-4">
|
||||
<p className="text-sm text-green-200">
|
||||
먼저 디스코드에서 <strong>음성 채널에 입장</strong>한 뒤, 노래를 검색해 재생 버튼을 누르세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-neutral-800 bg-black/20 p-4 sm:p-6">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-base font-bold sm:text-lg">
|
||||
<Music size={20} aria-hidden /> 상세 정보
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 sm:gap-4">
|
||||
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
|
||||
<p className="mb-1 text-neutral-500">서버 고유 ID</p>
|
||||
<p className="break-all 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 className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
|
||||
<p className="mb-1 text-neutral-500">나의 서버 권한</p>
|
||||
<p className="font-bold text-green-400">{getPermissionLabel(selectedServer)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,8 +325,8 @@ export default function MainContent({
|
||||
{/* 화면 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>
|
||||
<h2 className="mb-1 break-words text-xl font-bold sm:text-2xl">“{searchQuery}” 검색 결과</h2>
|
||||
<p className="mb-6 text-sm text-neutral-400">곡을 누르면 대기열에 추가됩니다.</p>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="flex flex-col items-center py-20">
|
||||
@@ -290,7 +347,7 @@ export default function MainContent({
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.spotify.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -302,7 +359,7 @@ export default function MainContent({
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.youtubeMusic.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -314,7 +371,7 @@ export default function MainContent({
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.youtubeVideo.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
|
||||
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play, ListMusic } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { DiscordServer, Track } from "@/types/music";
|
||||
|
||||
interface PlayerBarProps {
|
||||
selectedServer: DiscordServer | null;
|
||||
onToggleQueue?: () => void;
|
||||
isQueueOpen?: boolean;
|
||||
}
|
||||
|
||||
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen }: PlayerBarProps) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
// 재생 상태 관리
|
||||
@@ -251,136 +253,179 @@ export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||
// 진행도 퍼센트 계산 (0 ~ 100)
|
||||
const progressPercent = duration > 0 ? (position / duration) * 100 : 0;
|
||||
|
||||
const canControl = !!botPlayer && !!isPlaying && !!track;
|
||||
|
||||
return (
|
||||
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
|
||||
|
||||
{/* 1. 노래 정보 영역 */}
|
||||
<div className="flex items-center gap-4 w-1/4">
|
||||
{track ? (
|
||||
<>
|
||||
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md overflow-hidden">
|
||||
{track.info?.artworkUrl && <img src={track.info.artworkUrl} alt="cover" className="w-full h-full object-cover" />}
|
||||
</div>
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className="text-sm font-bold text-white truncate cursor-pointer hover:underline">{track.info?.title}</span>
|
||||
<span className="text-xs text-neutral-400 truncate cursor-pointer hover:underline">{track.info?.author?.replace(" - Topic", "")}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// 재생 중인 곡이 없을 때 빈 공간
|
||||
<div className="flex items-center gap-4 text-neutral-600">
|
||||
<div className="w-14 h-14 bg-neutral-900 rounded-md"></div>
|
||||
<span className="text-sm">재생 중인 곡 없음</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. 중앙 컨트롤러 영역 */}
|
||||
<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-600 cursor-not-allowed">
|
||||
<SkipBack size={20} />
|
||||
</button>
|
||||
|
||||
{/* 재생/일시정지 버튼 */}
|
||||
<button
|
||||
onClick={handleTogglePause}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center transition-transform text-black
|
||||
${track ? 'bg-white hover:scale-105' : 'bg-neutral-700 cursor-not-allowed'}`}
|
||||
>
|
||||
{!isPaused ? <Pause size={18} fill="black" /> : <Play size={18} fill="black" className="ml-0.5" />}
|
||||
</button>
|
||||
|
||||
{/* 다음 곡 스킵 버튼 */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
className={`transition-colors ${isPlaying && track ? 'text-neutral-400 hover:text-white' : 'text-neutral-600 cursor-not-allowed'}`}
|
||||
>
|
||||
<SkipForward size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 재생 바 (Range Input으로 구현하여 드래그/클릭 완벽 지원) */}
|
||||
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
|
||||
<span className="w-10 text-right">{formatTime(position)}</span>
|
||||
|
||||
<div className="flex-1 flex items-center group relative h-4">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
value={position}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
onChange={(e) => {
|
||||
isDragging.current = true;
|
||||
setPosition(Number(e.target.value)); // 드래그 중 화면 즉시 업데이트
|
||||
}}
|
||||
onMouseUp={handleSeekEnd}
|
||||
onTouchEnd={handleSeekEnd}
|
||||
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
||||
// ↑ 투명한 진짜 input을 덮어씌워서 클릭 이벤트를 완벽하게 받습니다.
|
||||
/>
|
||||
{/* 시각적으로 보여주는 커스텀 바 */}
|
||||
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
|
||||
<div
|
||||
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
{/* 동그란 핸들바 */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5"
|
||||
style={{ left: `${progressPercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<span className="w-10">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 우측 볼륨 영역 */}
|
||||
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
|
||||
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
|
||||
<div className="w-24 h-4 flex items-center group relative">
|
||||
<footer className="z-50 flex-shrink-0 border-t border-neutral-800 bg-neutral-950">
|
||||
{/* 모바일: 진행바를 맨 위에 가로로 꽉 채워 터치 영역을 넓게 확보한다. */}
|
||||
<div className="flex items-center gap-2 px-3 pt-2 text-[11px] tabular-nums text-neutral-400 sm:hidden">
|
||||
<span className="w-9 text-right">{formatTime(position)}</span>
|
||||
<div className="group relative flex h-6 flex-1 items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
disabled={!botPlayer || !isPlaying || !track}
|
||||
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
|
||||
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
|
||||
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
|
||||
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
||||
max={duration || 100}
|
||||
value={position}
|
||||
disabled={!canControl}
|
||||
aria-label="재생 위치"
|
||||
onChange={(e) => {
|
||||
isDragging.current = true;
|
||||
setPosition(Number(e.target.value));
|
||||
}}
|
||||
onMouseUp={handleSeekEnd}
|
||||
onTouchEnd={handleSeekEnd}
|
||||
className="absolute z-20 h-6 w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
{/* 커스텀 볼륨 바 디자인 */}
|
||||
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
|
||||
<div
|
||||
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
|
||||
style={{ width: `${volume}%` }}
|
||||
></div>
|
||||
<div className="pointer-events-none absolute z-10 h-1.5 w-full overflow-hidden rounded-full bg-neutral-700">
|
||||
<div className="h-full bg-green-500" style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
{/* 👇 [수정할 부분] 핸들바 안에 말풍선 툴팁 코드 추가 */}
|
||||
<div
|
||||
className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5 flex justify-center"
|
||||
style={{ left: `${volume}%` }}
|
||||
>
|
||||
{/* 드래그 중(isVolumeDragging === true)일 때만 나타나는 숫자 말풍선 */}
|
||||
{isVolumeDragging && (
|
||||
<div className="absolute bottom-4 bg-neutral-800 text-white text-[10px] font-bold py-1 px-2 rounded shadow-lg border border-neutral-700 whitespace-nowrap animate-in fade-in zoom-in duration-150">
|
||||
{volume}
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-9">{formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex h-20 items-center justify-between gap-3 px-3 sm:h-24 sm:px-6">
|
||||
{/* 1. 현재 곡 */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3 sm:w-1/4 sm:flex-none">
|
||||
<span className="h-12 w-12 flex-shrink-0 overflow-hidden rounded-md bg-neutral-800 sm:h-14 sm:w-14">
|
||||
{track?.info?.artworkUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={track.info.artworkUrl} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
{track ? (
|
||||
<>
|
||||
<span className="block truncate text-sm font-semibold text-white">{track.info?.title}</span>
|
||||
<span className="block truncate text-xs text-neutral-400">
|
||||
{track.info?.author?.replace(" - Topic", "")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="block text-sm text-neutral-400">재생 중인 곡 없음</span>
|
||||
<span className="hidden text-xs text-neutral-600 sm:block">검색해서 노래를 추가해보세요</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 2. 컨트롤 (모바일에서는 버튼만, 진행바는 위쪽에 따로) */}
|
||||
<div className="flex flex-shrink-0 flex-col items-center gap-1 sm:w-2/4 sm:max-w-2xl sm:flex-1">
|
||||
<div className="flex items-center gap-1 sm:gap-4">
|
||||
<button
|
||||
disabled
|
||||
title="이전 곡으로 돌아가는 기능은 아직 지원하지 않습니다"
|
||||
aria-label="이전 곡 (지원 예정)"
|
||||
className="hidden h-11 w-11 cursor-not-allowed items-center justify-center rounded-full text-neutral-700 sm:flex"
|
||||
>
|
||||
<SkipBack size={20} aria-hidden />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleTogglePause}
|
||||
disabled={!canControl}
|
||||
aria-label={isPaused ? "재생" : "일시정지"}
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full text-black transition
|
||||
${canControl ? "bg-white hover:scale-105 active:scale-95" : "cursor-not-allowed bg-neutral-700 text-neutral-500"}`}
|
||||
>
|
||||
{!isPaused ? <Pause size={22} fill="currentColor" aria-hidden /> : <Play size={22} fill="currentColor" className="ml-0.5" aria-hidden />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={!canControl}
|
||||
aria-label="다음 곡"
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-full transition-colors
|
||||
${canControl ? "text-neutral-300 hover:bg-neutral-800 hover:text-white" : "cursor-not-allowed text-neutral-700"}`}
|
||||
>
|
||||
<SkipForward size={20} aria-hidden />
|
||||
</button>
|
||||
|
||||
{/* 모바일 전용: 대기열 토글 */}
|
||||
{onToggleQueue && (
|
||||
<button
|
||||
onClick={onToggleQueue}
|
||||
aria-label={isQueueOpen ? "대기열 닫기" : "대기열 열기"}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800 hover:text-white lg:hidden"
|
||||
>
|
||||
<ListMusic size={20} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 데스크톱 진행바 */}
|
||||
<div className="hidden w-full items-center gap-2 text-xs tabular-nums text-neutral-400 sm:flex">
|
||||
<span className="w-10 text-right">{formatTime(position)}</span>
|
||||
<div className="group relative flex h-4 flex-1 items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={duration || 100}
|
||||
value={position}
|
||||
disabled={!canControl}
|
||||
aria-label="재생 위치"
|
||||
onChange={(e) => {
|
||||
isDragging.current = true;
|
||||
setPosition(Number(e.target.value));
|
||||
}}
|
||||
onMouseUp={handleSeekEnd}
|
||||
onTouchEnd={handleSeekEnd}
|
||||
className="absolute z-20 h-4 w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<div className="pointer-events-none absolute z-10 h-1 w-full overflow-hidden rounded-full bg-neutral-700">
|
||||
<div
|
||||
className="h-full bg-white transition-colors group-hover:bg-green-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 -ml-1.5 h-3 w-3 rounded-full bg-white opacity-0 shadow-md group-hover:opacity-100"
|
||||
style={{ left: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10">{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 볼륨 (좁은 화면에서는 숨긴다 — 기기 자체 볼륨으로 조절 가능) */}
|
||||
<div className="hidden w-1/4 items-center justify-end gap-2 text-neutral-400 sm:flex">
|
||||
<span aria-hidden>{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}</span>
|
||||
|
||||
<div className="group relative flex h-8 w-24 items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={volume}
|
||||
disabled={!canControl}
|
||||
aria-label="볼륨"
|
||||
aria-valuetext={`${volume}%`}
|
||||
onChange={handleVolumeChange}
|
||||
onMouseDown={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onTouchStart={() => { prevVolumeRef.current = volume; setIsVolumeDragging(true); }}
|
||||
onMouseUp={handleVolumeEnd}
|
||||
onTouchEnd={handleVolumeEnd}
|
||||
className="absolute z-20 h-8 w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
<div className="pointer-events-none absolute z-10 h-1 w-full overflow-hidden rounded-full bg-neutral-700">
|
||||
<div
|
||||
className="h-full bg-white transition-colors group-hover:bg-green-500"
|
||||
style={{ width: `${volume}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 -ml-1.5 flex h-3 w-3 justify-center rounded-full bg-white opacity-0 shadow-md group-hover:opacity-100"
|
||||
style={{ left: `${volume}%` }}
|
||||
>
|
||||
{isVolumeDragging && (
|
||||
<span className="absolute bottom-4 whitespace-nowrap rounded border border-neutral-700 bg-neutral-800 px-2 py-1 text-[10px] font-bold text-white shadow-lg">
|
||||
{volume}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
"use client";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Trash2, GripVertical, Music } from "lucide-react";
|
||||
import { Trash2, GripVertical, Music, ChevronUp, ChevronDown } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useToast } from "@/components/ToastProvider";
|
||||
import type { DiscordServer, Track } from "@/types/music";
|
||||
|
||||
interface QueueSidebarProps {
|
||||
selectedServer: DiscordServer | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
// 👇 [추가] 토스트 사용 준비 완료!
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [queue, setQueue] = useState<Track[]>([]);
|
||||
@@ -80,66 +79,68 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
};
|
||||
}, [selectedServer, status, fetchQueue]);
|
||||
|
||||
// 🌟 [수정됨] 드래그 앤 드롭 정렬 함수
|
||||
const handleSort = async () => {
|
||||
const fromIndex = dragItem.current;
|
||||
const toIndex = dragOverItem.current;
|
||||
|
||||
// 1. 마우스를 떼는 즉시 UI 애니메이션을 원래대로 되돌립니다.
|
||||
setDraggingIndex(null);
|
||||
setDragOverIndex(null);
|
||||
dragItem.current = null;
|
||||
dragOverItem.current = null;
|
||||
|
||||
// 드래그 위치가 없거나 서버가 없으면 종료
|
||||
if (fromIndex === null || toIndex === null || !selectedServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 실제 노래가 삽입될 최종 인덱스 계산
|
||||
let insertIndex = toIndex;
|
||||
if (fromIndex < toIndex && toIndex !== queue.length) {
|
||||
insertIndex = toIndex - 1;
|
||||
}
|
||||
|
||||
// 🌟 [신규 추가] 결과적으로 순서가 바뀌지 않았다면 여기서 바로 함수를 종료합니다! (API 요청 안 함)
|
||||
if (fromIndex === insertIndex) {
|
||||
console.log("순서가 변경되지 않아서 통신을 건너뜁니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 ---
|
||||
const userId = session?.user?.id;
|
||||
|
||||
// 3. 화면 즉시 업데이트
|
||||
const newQueue = [...queue];
|
||||
const draggedItem = newQueue.splice(fromIndex, 1)[0];
|
||||
newQueue.splice(insertIndex, 0, draggedItem);
|
||||
/** 바뀐 순서를 화면에 먼저 반영하고 서버에 보낸다(실패하면 되돌린다). */
|
||||
const applyReorder = async (newQueue: Track[]) => {
|
||||
if (!selectedServer) return;
|
||||
const previous = queue;
|
||||
setQueue(newQueue);
|
||||
|
||||
// 4. 서버(API)에 변경된 배열 전송
|
||||
try {
|
||||
const res = await fetch('/api/queue/set', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
serverId: selectedServer.id,
|
||||
userId: userId,
|
||||
newQueue: newQueue
|
||||
userId: session?.user?.id,
|
||||
newQueue,
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) {
|
||||
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
||||
fetchQueue(); // 실패 시 원래 큐로 복구
|
||||
setQueue(previous);
|
||||
fetchQueue();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("순서 동기화 실패:", error);
|
||||
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
|
||||
setQueue(previous);
|
||||
fetchQueue();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = async () => {
|
||||
const fromIndex = dragItem.current;
|
||||
const toIndex = dragOverItem.current;
|
||||
|
||||
setDraggingIndex(null);
|
||||
setDragOverIndex(null);
|
||||
dragItem.current = null;
|
||||
dragOverItem.current = null;
|
||||
|
||||
if (fromIndex === null || toIndex === null || !selectedServer) return;
|
||||
|
||||
let insertIndex = toIndex;
|
||||
if (fromIndex < toIndex && toIndex !== queue.length) {
|
||||
insertIndex = toIndex - 1;
|
||||
}
|
||||
if (fromIndex === insertIndex) return; // 순서가 그대로면 통신하지 않는다
|
||||
|
||||
const newQueue = [...queue];
|
||||
const draggedItem = newQueue.splice(fromIndex, 1)[0];
|
||||
newQueue.splice(insertIndex, 0, draggedItem);
|
||||
await applyReorder(newQueue);
|
||||
};
|
||||
|
||||
/** 터치 기기에서는 드래그앤드롭이 동작하지 않아, 한 칸씩 옮기는 버튼을 함께 제공한다. */
|
||||
const moveItem = async (index: number, direction: -1 | 1) => {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= queue.length) return;
|
||||
const newQueue = [...queue];
|
||||
[newQueue[index], newQueue[target]] = [newQueue[target], newQueue[index]];
|
||||
await applyReorder(newQueue);
|
||||
};
|
||||
|
||||
const handleDelete = async (indexToRemove: number) => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
@@ -171,26 +172,36 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
};
|
||||
|
||||
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>
|
||||
<aside className="flex h-full flex-col bg-neutral-950 p-4 lg:border-l lg:border-neutral-800">
|
||||
<div className="mb-3 flex items-center justify-between gap-2 pr-12 lg:pr-0">
|
||||
<h2 className="text-base font-bold">다음 재생 목록</h2>
|
||||
{queue.length > 0 && (
|
||||
<span className="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-300">
|
||||
{queue.length}곡
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-neutral-500 h-full gap-2">
|
||||
<Music size={32} className="opacity-50" />
|
||||
<p className="text-sm">대기열에 노래가 없습니다.</p>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center text-neutral-500">
|
||||
<Music size={32} className="opacity-50" aria-hidden />
|
||||
<p className="text-sm">대기 중인 노래가 없습니다.</p>
|
||||
<p className="text-xs text-neutral-600">
|
||||
검색해서 나온 곡의 재생 버튼을 누르면 여기에 쌓입니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col overflow-y-auto pr-2 pb-10">
|
||||
<ul className="flex flex-col gap-1 overflow-y-auto pb-24 pr-1">
|
||||
{queue.map((item, index) => {
|
||||
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
|
||||
const title = item.info?.title || "제목 없음";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={(item.encoded as string) || `${index}-${item.info?.title || ''}`}
|
||||
<li
|
||||
key={(item.encoded as string) || `${index}-${title}`}
|
||||
draggable
|
||||
onDragStart={() => {
|
||||
dragItem.current = index;
|
||||
// 🌟 마우스 잔상(고스트)이 까맣게 캡처되는 것을 막기 위한 0초 딜레이 트릭
|
||||
setTimeout(() => setDraggingIndex(index), 0);
|
||||
}}
|
||||
onDragEnter={() => {
|
||||
@@ -199,67 +210,80 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
|
||||
}}
|
||||
onDragEnd={handleSort}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
className={`relative flex items-center gap-3 group bg-neutral-900/50 p-2 rounded-md transition-all duration-200 ease-in-out cursor-grab active:cursor-grabbing border border-transparent hover:border-neutral-700 my-1
|
||||
${draggingIndex === index ? 'opacity-30 scale-95 shadow-inner' : 'opacity-100'}
|
||||
${isDropTarget ? 'mt-10' : 'mt-1'}
|
||||
`}
|
||||
className={`relative flex items-center gap-2 rounded-lg border border-transparent bg-neutral-900/60 p-2 transition-colors hover:border-neutral-700
|
||||
${draggingIndex === index ? "opacity-40" : "opacity-100"}
|
||||
${isDropTarget ? "border-green-500/70" : ""}`}
|
||||
>
|
||||
{/* 초록색 드롭 인디케이터 선 */}
|
||||
{isDropTarget && (
|
||||
<div className="absolute -top-5 left-0 right-0 h-0.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.8)] z-10" />
|
||||
)}
|
||||
|
||||
<div className="text-neutral-600 group-hover:text-neutral-400">
|
||||
{/* 드래그 핸들: 마우스 환경에서만 의미가 있어 큰 화면에서만 노출 */}
|
||||
<span className="hidden cursor-grab text-neutral-600 active:cursor-grabbing lg:block" aria-hidden>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div className="w-10 h-10 bg-neutral-700 rounded flex-shrink-0 overflow-hidden shadow-md">
|
||||
<span className="w-4 flex-shrink-0 text-center text-xs tabular-nums text-neutral-500">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
<span className="h-10 w-10 flex-shrink-0 overflow-hidden rounded bg-neutral-800">
|
||||
{item.info?.artworkUrl ? (
|
||||
<img src={item.info.artworkUrl} alt="" className="w-full h-full object-cover" />
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.info.artworkUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col overflow-hidden flex-1 pointer-events-none">
|
||||
<span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
|
||||
{item.info?.title || "제목 없음"}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400 truncate">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white">{title}</span>
|
||||
<span className="block truncate text-xs text-neutral-400">
|
||||
{item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(index);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-neutral-500 hover:text-red-500 transition-all p-1"
|
||||
title="대기열에서 삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{/* 조작 버튼: hover 로 숨기지 않는다(터치 기기에서는 hover 가 없어 아예 못 쓴다).
|
||||
순서 이동 버튼은 드래그가 안 되는 좁은 화면에서만 띄워 제목 폭을 확보한다. */}
|
||||
<span className="flex flex-shrink-0 items-center">
|
||||
<button
|
||||
onClick={() => moveItem(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="flex h-9 w-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 lg:hidden"
|
||||
aria-label={`${title} 위로 옮기기`}
|
||||
>
|
||||
<ChevronUp size={16} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveItem(index, 1)}
|
||||
disabled={index === queue.length - 1}
|
||||
className="flex h-9 w-8 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-white disabled:cursor-not-allowed disabled:opacity-30 lg:hidden"
|
||||
aria-label={`${title} 아래로 옮기기`}
|
||||
>
|
||||
<ChevronDown size={16} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(index)}
|
||||
className="flex h-9 w-9 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-red-400"
|
||||
aria-label={`${title} 대기열에서 빼기`}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🌟 [신규 추가] 맨 아래로 드래그하기 위한 전용 투명 드롭존 */}
|
||||
{/* 맨 아래로 드래그해 옮기기 위한 드롭존(마우스 전용) */}
|
||||
{draggingIndex !== null && (
|
||||
<div
|
||||
<li
|
||||
onDragEnter={() => {
|
||||
dragOverItem.current = queue.length;
|
||||
setDragOverIndex(queue.length);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
className={`relative h-12 flex items-center justify-center transition-all ${dragOverIndex === queue.length ? 'mt-4' : 'mt-0'
|
||||
}`}
|
||||
className="hidden h-10 items-center justify-center lg:flex"
|
||||
>
|
||||
{dragOverIndex === queue.length && (
|
||||
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.8)] z-10" />
|
||||
<span className="h-0.5 w-full rounded-full bg-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user