실제 화면을 캡처해 확인한 문제: - 모바일(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)에서 캡처해 로그인 전/서버 목록/서버 상세/검색 결과/대기열 패널 화면을 모두 확인했다.
140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
"use client";
|
|
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
|
|
import { signIn, signOut, useSession } from "next-auth/react";
|
|
import { useState } from "react";
|
|
import type { DiscordServer } from "@/types/music";
|
|
|
|
interface TopNavProps {
|
|
onSearch: (query: string) => void;
|
|
onHome: () => 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";
|
|
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 isAuthed = status === "authenticated";
|
|
const canSearch = isAuthed && !!selectedServer;
|
|
|
|
const submitSearch = () => {
|
|
if (canSearch && term.trim()) onSearch(term.trim());
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") submitSearch();
|
|
};
|
|
|
|
const handleHomeClick = () => {
|
|
setTerm("");
|
|
onHome();
|
|
};
|
|
|
|
// 상황별로 검색창 안내를 다르게 준다(로그인 전인데 "서버를 고르라"고 하면 혼란스럽다).
|
|
const searchPlaceholder = !isAuthed
|
|
? "로그인하면 검색할 수 있어요"
|
|
: !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>
|
|
)}
|
|
|
|
{/* 검색: 남는 폭을 모두 차지하게 해서 모바일에서도 쓸 만하게 만든다. */}
|
|
<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>
|
|
|
|
{/* 계정 영역 */}
|
|
<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={() => 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>
|
|
</nav>
|
|
</header>
|
|
);
|
|
}
|