feat: 로그인·서버 선택 페이지 분리 + 플레이어 기능 추가(섞기·자동재생·주소 불러오기 등)
page - /login(디스코드 로그인 전용), /servers(서버 선택 전용), /servers/[id](유튜브 뮤직 플레이어)로 분리 · 서버 컴포넌트에서 로그인 검사, callbackUrl 은 같은 사이트 경로만 허용 · NextAuth 로그인/오류 화면도 /login 으로 - 서버 선택: 최근 사용 서버 맨 앞 "최근" 표시, 새로고침, 8개 초과 시 이름 검색, 오류 표시 - 플레이어: 검색어를 ?q= 로 남겨 뒤로가기 지원, 탭 제목에 지금 곡, 내 서버가 아니면 안내 - 검색창: 최근 검색어 목록, 유튜브·스포티파이 주소를 붙여넣으면 곡/재생목록 바로 불러오기 - 다음 트랙: 섞기, 모두 지우기(두 번 눌러 확인), 자동재생 스위치, 곡 수·총 길이 - 재생바: 처음부터 다시(⏮), 섞기 버튼 / 서버 홈: 음성채널 표시, 사용 안내, 최근 검색 칩 - 곡 추가 중 스피너 + 중복 추가 방지, 검색 실패 시 다시 시도, play/playlist 응답 대기 15초 - 계정 전환형 프로필 메뉴와 사이드바 서버 목록 제거(프로필은 서버 변경·로그아웃만) bot - Redis 액션 queue_shuffle, player_recommend 추가 - player_now 응답에 recommend(자동재생), voiceChannelName 추가 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,62 +1,49 @@
|
||||
"use client";
|
||||
import { ArrowLeft, LogOut, Menu, Search, X } from "lucide-react";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
import { ArrowLeft, Clock, Link2, Menu, Search, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { DiscordServer } from "@/types/music";
|
||||
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;
|
||||
selectedServer: DiscordServer | null;
|
||||
}
|
||||
|
||||
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */
|
||||
function Avatar({ user, className = "h-8 w-8 text-sm" }: { user: { name?: string | null; image?: string | null }; className?: string }) {
|
||||
if (user.image) {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={user.image} alt="" className={`${className} rounded-full object-cover`} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className={`${className} flex items-center justify-center rounded-full bg-[#7b1fa2] font-medium text-white`}
|
||||
aria-hidden
|
||||
>
|
||||
{(user.name ?? "?").trim().charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유튜브 뮤직 상단 바: [☰ 로고] [검색창] ......... [프로필]
|
||||
* 좁은 화면에서는 검색창 대신 돋보기 버튼을 두고, 누르면 바 전체가 검색창으로 바뀐다(유튜브 뮤직 모바일과 같은 방식).
|
||||
* - 검색창을 누르면 최근 검색어가 펼쳐진다(유튜브 뮤직과 같은 방식).
|
||||
* - 좁은 화면은 돋보기 버튼 → 바 전체가 검색창으로 바뀐다.
|
||||
*/
|
||||
export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer }: TopNavProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const [term, setTerm] = useState("");
|
||||
export default function TopNav({ query, onSearch, onHome, onToggleGuide }: TopNavProps) {
|
||||
const [term, setTerm] = useState(query);
|
||||
const [mobileSearch, setMobileSearch] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isAuthed = status === "authenticated";
|
||||
const canSearch = isAuthed && !!selectedServer;
|
||||
// 화면의 검색어가 바뀌면(뒤로가기·홈) 입력칸도 맞춘다.
|
||||
useEffect(() => {
|
||||
|
||||
setTerm(query);
|
||||
}, [query]);
|
||||
|
||||
const submitSearch = () => {
|
||||
if (canSearch && term.trim()) {
|
||||
onSearch(term.trim());
|
||||
inputRef.current?.blur();
|
||||
setMobileSearch(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const sync = () => setRecent(readRecent());
|
||||
sync();
|
||||
window.addEventListener(RECENT_EVENT, sync);
|
||||
return () => window.removeEventListener(RECENT_EVENT, sync);
|
||||
}, []);
|
||||
|
||||
// 서버 페이지의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
|
||||
// 서버 홈의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
|
||||
useEffect(() => {
|
||||
const focus = () => {
|
||||
setMobileSearch(true);
|
||||
@@ -66,32 +53,39 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer
|
||||
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus);
|
||||
}, []);
|
||||
|
||||
// 프로필 메뉴: 바깥을 누르거나 Esc 로 닫는다.
|
||||
// 검색창 바깥을 누르면 최근 검색어 목록을 닫는다.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
if (!focused) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(false);
|
||||
if (!boxRef.current?.contains(e.target as Node)) setFocused(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setMenuOpen(false);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [focused]);
|
||||
|
||||
const handleLogoClick = () => {
|
||||
setTerm("");
|
||||
onHome();
|
||||
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 searchPlaceholder = !isAuthed
|
||||
? "로그인하면 검색할 수 있어요"
|
||||
: !selectedServer
|
||||
? "먼저 서버를 선택해주세요"
|
||||
: "노래, 아티스트, 유튜브·스포티파이 주소 검색";
|
||||
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">
|
||||
@@ -101,7 +95,10 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer
|
||||
<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)}
|
||||
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"
|
||||
>
|
||||
@@ -120,37 +117,37 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer
|
||||
>
|
||||
<Menu size={22} aria-hidden />
|
||||
</button>
|
||||
<button type="button" onClick={handleLogoClick} aria-label="홈으로" className="rounded px-1 py-1">
|
||||
<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();
|
||||
submitSearch();
|
||||
run(term);
|
||||
}}
|
||||
className="group relative w-full max-w-[480px]"
|
||||
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
|
||||
/>
|
||||
<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)}
|
||||
disabled={!canSearch}
|
||||
placeholder={searchPlaceholder}
|
||||
onFocus={() => setFocused(true)}
|
||||
onKeyDown={(e) => e.key === "Escape" && setFocused(false)}
|
||||
placeholder="노래·아티스트 검색 또는 유튜브·스포티파이 주소 붙여넣기"
|
||||
aria-label="노래 검색"
|
||||
className="h-10 w-full rounded-lg 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 disabled:cursor-not-allowed disabled:opacity-60 [&::-webkit-search-cancel-button]:hidden"
|
||||
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
|
||||
@@ -165,6 +162,42 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer
|
||||
<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>
|
||||
|
||||
@@ -172,62 +205,18 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer
|
||||
|
||||
{/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
{canSearch && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{status === "loading" ? (
|
||||
<span className="h-8 w-8 animate-pulse rounded-full bg-yt-chip" aria-label="로그인 상태 확인 중" />
|
||||
) : isAuthed && session?.user ? (
|
||||
<div ref={menuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-label="계정 메뉴"
|
||||
aria-expanded={menuOpen}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<Avatar user={session.user} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 top-12 z-50 w-72 overflow-hidden rounded-lg bg-yt-menu py-2 shadow-2xl ring-1 ring-white/10">
|
||||
<div className="flex items-center gap-4 border-b border-yt-line px-4 pb-4 pt-2">
|
||||
<Avatar user={session.user} className="h-10 w-10 text-base" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-base text-white">{session.user.name}</span>
|
||||
<span className="block truncate text-sm text-white/70">Discord 계정</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
className="flex h-10 w-full items-center gap-4 px-4 text-left text-sm text-white hover:bg-white/10"
|
||||
>
|
||||
<LogOut size={20} className="text-white/70" aria-hidden />
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn("discord")}
|
||||
className="h-9 rounded-full bg-white px-4 text-sm font-medium text-black transition-colors hover:bg-white/85"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user