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

@@ -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,29 +35,38 @@ 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">
<TopNav
onSearch={handleSearch}
onHome={handleHome}
selectedServer={selectedServer} // 🌟 이 줄을 추가해서 선택된 서버 정보를 넘깁니다.
<div className="flex h-dvh flex-col overflow-hidden bg-neutral-950 font-sans text-white">
<TopNav
onSearch={handleSearch}
onHome={handleHome}
selectedServer={selectedServer}
/>
<div className="flex flex-1 overflow-hidden">
<LeftSidebar />
<MainContent
<div className="relative flex flex-1 overflow-hidden">
{/* 좌측 사이드바: 보조 정보라 넓은 화면에서만 노출한다(모바일 공간 절약). */}
{showPlayback && (
<LeftSidebar selectedServer={selectedServer} onBackToServers={handleBackToServers} />
)}
<MainContent
viewMode={viewMode}
setViewMode={setViewMode}
selectedServer={selectedServer}
@@ -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>
);
}
}