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:
2026-10-03 14:08:29 +09:00
parent a491a1a98e
commit 7021f2b776
30 changed files with 1384 additions and 625 deletions

View File

@@ -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>