Files
music_bot_v2/page/src/components/layout/TopNav.tsx
2026-10-03 14:17:02 +09:00

225 lines
8.9 KiB
TypeScript

"use client";
import { ArrowLeft, Clock, Link2, Menu, Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Logo } from "@/components/ui";
import AccountMenu from "@/components/layout/AccountMenu";
import { isMusicUrl } from "@/lib/url";
import { RECENT_EVENT, RECENT_MAX, readRecent, writeRecent } from "@/lib/recent";
/** 다른 화면(서버 홈의 "노래 검색" 버튼 등)에서 검색창을 열 때 쓰는 이벤트 이름 */
export const FOCUS_SEARCH_EVENT = "musicbot:focus-search";
interface TopNavProps {
/** 지금 화면에 걸린 검색어(뒤로가기 등으로 바뀌면 입력칸도 맞춘다) */
query: string;
onSearch: (query: string) => void;
onHome: () => void;
onToggleGuide: () => void;
}
/**
* 유튜브 뮤직 상단 바: [☰ 로고] [검색창] ......... [프로필]
* - 검색창을 누르면 최근 검색어가 펼쳐진다(유튜브 뮤직과 같은 방식).
* - 좁은 화면은 돋보기 버튼 → 바 전체가 검색창으로 바뀐다.
*/
export default function TopNav({ query, onSearch, onHome, onToggleGuide }: TopNavProps) {
const [term, setTerm] = useState(query);
const [mobileSearch, setMobileSearch] = useState(false);
const [focused, setFocused] = useState(false);
const [recent, setRecent] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const boxRef = useRef<HTMLDivElement>(null);
// 화면의 검색어가 바뀌면(뒤로가기·홈) 입력칸도 맞춘다.
useEffect(() => {
setTerm(query);
}, [query]);
useEffect(() => {
const sync = () => setRecent(readRecent());
sync();
window.addEventListener(RECENT_EVENT, sync);
return () => window.removeEventListener(RECENT_EVENT, sync);
}, []);
// 서버 홈의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
useEffect(() => {
const focus = () => {
setMobileSearch(true);
requestAnimationFrame(() => inputRef.current?.focus());
};
window.addEventListener(FOCUS_SEARCH_EVENT, focus);
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus);
}, []);
// 검색창 바깥을 누르면 최근 검색어 목록을 닫는다.
useEffect(() => {
if (!focused) return;
const onDown = (e: MouseEvent) => {
if (!boxRef.current?.contains(e.target as Node)) setFocused(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [focused]);
const run = (value: string) => {
const q = value.trim();
if (!q) return;
setTerm(q);
// 주소는 최근 검색어에 남기지 않는다(길고 다시 쓸 일이 적다).
if (!isMusicUrl(q)) {
const next = [q, ...recent.filter((r) => r !== q)].slice(0, RECENT_MAX);
setRecent(next);
writeRecent(next);
}
onSearch(q);
setFocused(false);
inputRef.current?.blur();
setMobileSearch(false);
};
const removeRecent = (r: string) => {
const next = recent.filter((x) => x !== r);
setRecent(next);
writeRecent(next);
};
const showDropdown = focused && (recent.length > 0 || isMusicUrl(term));
return (
<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);
setFocused(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="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={onHome} aria-label="홈으로" className="rounded px-1 py-1">
<Logo />
</button>
</div>
{/* 검색창 */}
<div
ref={boxRef}
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();
run(term);
}}
className="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)}
onFocus={() => setFocused(true)}
onKeyDown={(e) => e.key === "Escape" && setFocused(false)}
placeholder="노래·아티스트 검색 또는 유튜브·스포티파이 주소 붙여넣기"
aria-label="노래 검색"
autoComplete="off"
className={`h-10 w-full 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 [&::-webkit-search-cancel-button]:hidden
${showDropdown ? "rounded-t-lg" : "rounded-lg"}`}
/>
{term && (
<button
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"
>
<X size={18} aria-hidden />
</button>
)}
{/* 최근 검색어 / 주소 안내 */}
{showDropdown && (
<div className="absolute inset-x-0 top-10 z-50 overflow-hidden rounded-b-lg border border-t-0 border-white/20 bg-yt-raised py-2 shadow-2xl">
{isMusicUrl(term) ? (
<button
type="submit"
className="flex h-11 w-full items-center gap-4 px-4 text-left text-sm text-white hover:bg-white/10"
>
<Link2 size={18} className="flex-shrink-0 text-white/70" aria-hidden />
<span className="truncate">이 주소의 곡·재생목록 불러오기</span>
</button>
) : (
recent.map((r) => (
<div key={r} className="group flex h-11 items-center hover:bg-white/10">
<button
type="button"
onClick={() => run(r)}
className="flex h-full min-w-0 flex-1 items-center gap-4 pl-4 text-left text-sm text-white"
>
<Clock size={18} className="flex-shrink-0 text-white/70" aria-hidden />
<span className="truncate">{r}</span>
</button>
<button
type="button"
onClick={() => removeRecent(r)}
aria-label={`최근 검색어 ${r} 삭제`}
className="mr-2 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-white/50 hover:text-white"
>
<X size={16} aria-hidden />
</button>
</div>
))
)}
</div>
)}
</form>
</div>
<div className="flex-1 md:hidden" />
{/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
<div className="flex flex-shrink-0 items-center gap-1">
<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>
<AccountMenu />
</div>
</nav>
</header>
);
}