225 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|