feat(page): 대시보드 디자인을 유튜브 뮤직(웹) 다크 테마로 전면 교체
- 상단 바: ☰ + 로고 + 반투명 검색창 + 프로필 메뉴(좁은 화면은 돋보기 → 전체 폭 검색) - 왼쪽 메뉴: 홈/서버 목록 + 봇이 있는 내 서버 목록, 넓은 화면 펼침(240)/접힘(72), 좁은 화면 서랍 - 홈: 서버를 원형 카드로, 서버 페이지: 앨범 페이지 형태(왼쪽 아이콘·정보, 오른쪽 재생 목록) - 검색: 필터 칩(전체/노래/동영상/Spotify) + 상위 검색결과 카드 + 구역별 목록(모두 표시) - 하단 재생바: 위쪽 빨간 진행선, 조작·시간 / 곡 정보 / hover 볼륨·재생 화면(⌃) - 재생 화면: 큰 앨범아트 + 다음 트랙 탭, 좁은 화면은 전체 화면 플레이어 - 재생/대기열/서버 로직을 hooks(usePlayer/useQueue/useServers)로 분리해 구독을 한 번만 연다 - 스낵바 알림, 파비콘, README 화면·버튼 문서 갱신 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
198
page/src/components/player/QueueList.tsx
Normal file
198
page/src/components/player/QueueList.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
import { useRef, useState } from "react";
|
||||
import { ChevronDown, ChevronUp, GripVertical, Play, Trash2 } from "lucide-react";
|
||||
import { Equalizer, Thumb } from "@/components/ui";
|
||||
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
|
||||
import type { QueueState } from "@/hooks/useQueue";
|
||||
|
||||
interface QueueListProps {
|
||||
player: PlayerState;
|
||||
queue: QueueState;
|
||||
/** page = 서버 페이지의 트랙 목록, panel = 재생 화면의 "다음 트랙" 탭 */
|
||||
variant: "page" | "panel";
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 뮤직 "다음 트랙" 목록.
|
||||
* 맨 위에 지금 재생 중인 곡(이퀄라이저 표시), 그 아래 대기열.
|
||||
* - 곡 누르기: 그 곡부터 재생(앞 곡들은 대기열에서 빠진다)
|
||||
* - 마우스: 왼쪽 손잡이로 드래그해 순서 변경, hover 시 삭제 버튼
|
||||
* - 터치 화면: hover 가 없으니 ▲▼·삭제 버튼을 항상 보여준다
|
||||
*/
|
||||
export default function QueueList({ player, queue, variant }: QueueListProps) {
|
||||
const { track: nowPlaying, isPaused } = player;
|
||||
const { queue: items, moveTo, moveBy, playFrom, remove } = queue;
|
||||
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
const dragItem = useRef<number | null>(null);
|
||||
const dragOverItem = useRef<number | null>(null);
|
||||
|
||||
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) return;
|
||||
await moveTo(fromIndex, toIndex);
|
||||
};
|
||||
|
||||
const isPage = variant === "page";
|
||||
const thumbSize = "h-10 w-10";
|
||||
|
||||
return (
|
||||
<div className={isPage ? "" : "pb-6"}>
|
||||
{isPage && (
|
||||
<div className="mb-2 flex items-baseline justify-between gap-2 border-b border-yt-line pb-3">
|
||||
<h2 className="text-2xl font-bold">재생 목록</h2>
|
||||
<span className="text-sm text-white/50">
|
||||
{items.length > 0 ? `다음 트랙 ${items.length}곡` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지금 재생 중 */}
|
||||
{nowPlaying && (
|
||||
<div className="flex items-center gap-4 rounded-md bg-white/10 px-2 py-2">
|
||||
<span className="relative">
|
||||
<Thumb src={nowPlaying.info?.artworkUrl} className={thumbSize} />
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/50 text-white">
|
||||
<Equalizer paused={isPaused} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white">
|
||||
{nowPlaying.info?.title || "제목 없음"}
|
||||
</span>
|
||||
<span className="block truncate text-sm text-white/70">
|
||||
{cleanArtist(nowPlaying.info?.author) || "알 수 없는 아티스트"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 pr-2 text-sm tabular-nums text-white/70">
|
||||
{formatTime(nowPlaying.info?.length)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 px-4 py-16 text-center">
|
||||
<p className="text-base text-white">대기 중인 노래가 없습니다.</p>
|
||||
<p className="text-sm text-white/50">검색해서 나온 곡을 누르면 여기에 쌓입니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{nowPlaying && (
|
||||
<p className="px-2 pb-1 pt-4 text-xs font-medium text-white/50">다음 트랙</p>
|
||||
)}
|
||||
<ul>
|
||||
{items.map((item, index) => {
|
||||
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
|
||||
const title = item.info?.title || "제목 없음";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={(item.encoded as string) || `${index}-${title}`}
|
||||
draggable
|
||||
onDragStart={() => {
|
||||
dragItem.current = index;
|
||||
setTimeout(() => setDraggingIndex(index), 0);
|
||||
}}
|
||||
onDragEnter={() => {
|
||||
dragOverItem.current = index;
|
||||
setDragOverIndex(index);
|
||||
}}
|
||||
onDragEnd={handleSort}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
className={`group relative flex items-center gap-1 rounded-md px-1 py-1 transition-colors hover:bg-white/10
|
||||
${draggingIndex === index ? "opacity-40" : ""}
|
||||
${isDropTarget ? "shadow-[inset_0_2px_0_0_#fff]" : ""}`}
|
||||
>
|
||||
{/* 드래그 손잡이(마우스 전용) */}
|
||||
<span
|
||||
className="flex w-5 flex-shrink-0 cursor-grab justify-center text-white/50 opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing pointer-coarse:hidden"
|
||||
aria-hidden
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => playFrom(index)}
|
||||
className="flex min-w-0 flex-1 items-center gap-4 rounded py-1 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
||||
aria-label={`${title} 부터 재생`}
|
||||
title="이 곡부터 재생"
|
||||
>
|
||||
<span className="relative">
|
||||
<Thumb src={item.info?.artworkUrl} className={thumbSize} />
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Play fill="white" stroke="white" size={16} aria-hidden />
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white">{title}</span>
|
||||
<span className="block truncate text-sm text-white/70">
|
||||
{cleanArtist(item.info?.author) || "알 수 없는 아티스트"}
|
||||
</span>
|
||||
</span>
|
||||
{/* 길이: 마우스 hover 때는 삭제 버튼에 자리를 내준다 */}
|
||||
<span className="flex-shrink-0 text-sm tabular-nums text-white/70 group-hover:hidden pointer-coarse:hidden">
|
||||
{formatTime(item.info?.length)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span className="hidden flex-shrink-0 items-center group-focus-within:flex group-hover:flex pointer-coarse:flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveBy(index, -1)}
|
||||
disabled={index === 0}
|
||||
className="hidden h-10 w-9 items-center justify-center rounded-full text-white/70 hover:text-white disabled:opacity-30 pointer-coarse:flex"
|
||||
aria-label={`${title} 위로 옮기기`}
|
||||
>
|
||||
<ChevronUp size={18} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveBy(index, 1)}
|
||||
disabled={index === items.length - 1}
|
||||
className="hidden h-10 w-9 items-center justify-center rounded-full text-white/70 hover:text-white disabled:opacity-30 pointer-coarse:flex"
|
||||
aria-label={`${title} 아래로 옮기기`}
|
||||
>
|
||||
<ChevronDown size={18} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label={`${title} 대기열에서 삭제`}
|
||||
title="대기열에서 삭제"
|
||||
>
|
||||
<Trash2 size={18} aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 맨 아래로 드래그해 옮기기 위한 드롭존(마우스 전용) */}
|
||||
{draggingIndex !== null && (
|
||||
<li
|
||||
onDragEnter={() => {
|
||||
dragOverItem.current = items.length;
|
||||
setDragOverIndex(items.length);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
className="flex h-10 items-start"
|
||||
>
|
||||
{dragOverIndex === items.length && <span className="h-0.5 w-full bg-white" />}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user