feat(page): 대시보드 UI/UX 전면 개선 — 모바일 지원, 터치 조작, 접근성

실제 화면을 캡처해 확인한 문제:
- 모바일(390px)에서 3열 고정 레이아웃(사이드바 240 + 대기열 320)이 본문을 ~50px 로 눌러
  "로그인이 필요합니다"가 한 글자씩 세로로 표시되는 등 사실상 사용 불가였다.
- 대기열의 삭제/드래그가 hover 에서만 노출돼, hover 가 없는 터치 기기에서는 접근 자체가 불가능.
  순서 변경도 HTML5 드래그앤드롭뿐이라 모바일에서 동작하지 않았다.
- 조작 요소가 터치 기준(44px)에 한참 못 미쳤다(재생 32px, 삭제 ~24px, 진행바 높이 1px).
- 로그아웃 상태에서도 사이드바·대기열·플레이어바가 자리를 차지했고, 본문은 "로그인이 필요합니다"
  라고만 적어둔 채 정작 버튼은 우상단에만 있어 동선이 끊겼다.
- 아이콘 버튼에 접근성 이름이 없고(title 만), 검색창 안내가 로그인 전에도 "서버를 먼저 선택"이었다.

변경:
- 레이아웃: 반응형으로 재구성. 좁은 화면은 단일 컬럼 + 대기열을 슬라이드 패널(FAB/플레이어바에서 토글),
  넓은 화면은 기존 3열 유지. 로그인 전·서버 미선택 시에는 재생/대기열 영역을 아예 렌더링하지 않는다.
- 대기열: 조작 버튼 상시 노출, 좁은 화면에는 위/아래 이동 버튼 제공(드래그 대체), 넓은 화면은 드래그 유지.
  번호·곡수 표시 추가, 실패 시 이전 순서로 롤백.
- 플레이어바: 버튼을 44~48px 로 키우고 모바일은 진행바를 상단 전체 폭으로 분리. 볼륨은 좁은 화면에서 숨김.
- 검색 결과/서버 카드: 카드 전체를 버튼으로 만들어 hover 없이도 누를 수 있게 하고 재생 아이콘 상시 노출.
- 로그인 화면에 실제 로그인 버튼 배치, 서버 미선택/결과 없음 등 빈 상태 문구 정리.
- 좌측 사이드바를 안내문 전용 죽은 공간에서 "조작 중인 서버 + 서버 전환 + 사용법"으로 교체.
- 아이콘 버튼 aria-label, range input aria-label, 장식 이미지 alt="" 정리. 프로필 이미지 없을 때 이니셜 대체.

검증: tsc 통과, lint 에러 0. 실제 렌더링을 데스크톱(1440x900)·모바일(390x844)에서 캡처해
로그인 전/서버 목록/서버 상세/검색 결과/대기열 패널 화면을 모두 확인했다.
This commit is contained in:
2026-09-29 13:47:26 +09:00
parent 1021a61ead
commit 5f8dc184b4
6 changed files with 646 additions and 350 deletions

View File

@@ -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} />
<input
type="text"
{/* 검색: 남는 폭을 모두 차지하게 해서 모바일에서도 쓸 만하게 만든다. */}
<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={!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>
);
}
}