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:
2026-10-03 05:04:49 +09:00
parent 231dc6d716
commit a491a1a98e
16 changed files with 2026 additions and 1410 deletions

View 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>
);
}