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

@@ -1,33 +1,31 @@
"use client";
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
import { ArrowLeft, LogOut, Menu, Search, X } from "lucide-react";
import { signIn, signOut, useSession } from "next-auth/react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { DiscordServer } from "@/types/music";
import { Logo } from "@/components/ui";
/** 다른 화면(서버 페이지의 "노래 검색" 버튼 등)에서 검색창을 열 때 쓰는 이벤트 이름 */
export const FOCUS_SEARCH_EVENT = "musicbot:focus-search";
interface TopNavProps {
onSearch: (query: string) => void;
onHome: () => void;
onToggleGuide: () => void;
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";
function Avatar({ user, className = "h-8 w-8 text-sm" }: { user: { name?: string | null; image?: string | null }; className?: string }) {
if (user.image) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={user.image} alt="" className={`${box} rounded-full object-cover`} />
<img src={user.image} alt="" className={`${className} rounded-full object-cover`} />
);
}
return (
<span
className={`${box} flex items-center justify-center rounded-full bg-neutral-700 font-semibold text-white`}
className={`${className} flex items-center justify-center rounded-full bg-[#7b1fa2] font-medium text-white`}
aria-hidden
>
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
@@ -35,22 +33,55 @@ function Avatar({
);
}
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
/**
* 유튜브 뮤직 상단 바: [☰ 로고] [검색창] ......... [프로필]
* 좁은 화면에서는 검색창 대신 돋보기 버튼을 두고, 누르면 바 전체가 검색창으로 바뀐다(유튜브 뮤직 모바일과 같은 방식).
*/
export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer }: TopNavProps) {
const { data: session, status } = useSession();
const [term, setTerm] = useState("");
const [mobileSearch, setMobileSearch] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const isAuthed = status === "authenticated";
const canSearch = isAuthed && !!selectedServer;
const submitSearch = () => {
if (canSearch && term.trim()) onSearch(term.trim());
if (canSearch && term.trim()) {
onSearch(term.trim());
inputRef.current?.blur();
setMobileSearch(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") submitSearch();
};
// 서버 페이지의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
useEffect(() => {
const focus = () => {
setMobileSearch(true);
requestAnimationFrame(() => inputRef.current?.focus());
};
window.addEventListener(FOCUS_SEARCH_EVENT, focus);
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus);
}, []);
const handleHomeClick = () => {
// 프로필 메뉴: 바깥을 누르거나 Esc 로 닫는다.
useEffect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMenuOpen(false);
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [menuOpen]);
const handleLogoClick = () => {
setTerm("");
onHome();
};
@@ -60,76 +91,141 @@ export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps
? "로그인하면 검색할 수 있어요"
: !selectedServer
? "먼저 서버를 선택해주세요"
: "노래 제목이나 유튜브·스포티파이 주소";
: "노래, 아티스트, 유튜브·스포티파이 주소 검색";
return (
<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>
{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>
<header className="z-40 flex-shrink-0 border-b border-yt-line bg-yt-bg">
<nav className="relative flex h-16 items-center gap-2 px-2 sm:px-4">
{/* 좁은 화면 검색 모드: 뒤로 + 검색창이 바 전체를 차지한다 */}
{mobileSearch && (
<div className="absolute inset-0 z-10 flex items-center gap-2 bg-yt-bg px-2 md:hidden">
<button
type="button"
onClick={() => setMobileSearch(false)}
aria-label="검색 닫기"
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white hover:bg-yt-chip"
>
<ArrowLeft size={22} aria-hidden />
</button>
</div>
)}
{/* 검색: 남는 폭을 모두 차지하게 해서 모바일에서도 쓸 만하게 만든다. */}
<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="search"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={handleKeyDown}
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 className="flex flex-shrink-0 items-center gap-1 lg:w-[232px]">
<button
type="button"
onClick={onToggleGuide}
aria-label="메뉴"
className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-yt-chip"
>
<Menu size={22} aria-hidden />
</button>
<button type="button" onClick={handleLogoClick} aria-label="홈으로" className="rounded px-1 py-1">
<Logo />
</button>
</div>
{/* 계정 영역 */}
<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>
{/* 검색창: 유튜브 뮤직과 같은 반투명 상자 + 왼쪽 돋보기 + 지우기 버튼 */}
<div
className={`min-w-0 flex-1 md:block ${mobileSearch ? "absolute inset-y-0 left-14 right-2 z-20 flex items-center md:static" : "hidden"}`}
>
<form
role="search"
onSubmit={(e) => {
e.preventDefault();
submitSearch();
}}
className="group relative w-full max-w-[480px]"
>
<Search
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/70"
size={20}
aria-hidden
/>
<input
ref={inputRef}
type="search"
value={term}
onChange={(e) => setTerm(e.target.value)}
disabled={!canSearch}
placeholder={searchPlaceholder}
aria-label="노래 검색"
className="h-10 w-full rounded-lg border border-white/20 bg-white/15 pl-12 pr-10 text-base text-white placeholder:text-white/50 focus:border-white/30 focus:bg-yt-raised focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 [&::-webkit-search-cancel-button]:hidden"
/>
{term && (
<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="로그아웃"
type="button"
onClick={() => {
setTerm("");
inputRef.current?.focus();
}}
aria-label="검색어 지우기"
className="absolute right-1 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white/70 hover:text-white"
>
<LogOut size={18} aria-hidden />
<X size={18} aria-hidden />
</button>
)}
</form>
</div>
<div className="flex-1 md:hidden" />
{/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
<div className="flex flex-shrink-0 items-center gap-1">
{canSearch && (
<button
type="button"
onClick={() => {
setMobileSearch(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
aria-label="검색"
className="flex h-10 w-10 items-center justify-center rounded-full text-white hover:bg-yt-chip md:hidden"
>
<Search size={22} aria-hidden />
</button>
)}
{status === "loading" ? (
<span className="h-8 w-8 animate-pulse rounded-full bg-yt-chip" aria-label="로그인 상태 확인 중" />
) : isAuthed && session?.user ? (
<div ref={menuRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
aria-label="계정 메뉴"
aria-expanded={menuOpen}
className="flex h-10 w-10 items-center justify-center rounded-full"
>
<Avatar user={session.user} />
</button>
{menuOpen && (
<div className="absolute right-0 top-12 z-50 w-72 overflow-hidden rounded-lg bg-yt-menu py-2 shadow-2xl ring-1 ring-white/10">
<div className="flex items-center gap-4 border-b border-yt-line px-4 pb-4 pt-2">
<Avatar user={session.user} className="h-10 w-10 text-base" />
<span className="min-w-0">
<span className="block truncate text-base text-white">{session.user.name}</span>
<span className="block truncate text-sm text-white/70">Discord 계정</span>
</span>
</div>
<button
type="button"
onClick={() => signOut()}
className="flex h-10 w-full items-center gap-4 px-4 text-left text-sm text-white hover:bg-white/10"
>
<LogOut size={20} className="text-white/70" aria-hidden />
로그아웃
</button>
</div>
)}
</div>
) : (
<button
type="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"
className="h-9 rounded-full bg-white px-4 text-sm font-medium text-black transition-colors hover:bg-white/85"
>
<LogIn size={16} aria-hidden />
<span className="hidden sm:inline">Discord 로그인</span>
<span className="sm:hidden">로그인</span>
로그인
</button>
)}
</div>