From a491a1a98e2b5329149144f0e9ae31e0e9843114 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Sat, 3 Oct 2026 05:04:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(page):=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EB=94=94=EC=9E=90=EC=9D=B8=EC=9D=84=20=EC=9C=A0?= =?UTF-8?q?=ED=8A=9C=EB=B8=8C=20=EB=AE=A4=EC=A7=81(=EC=9B=B9)=20=EB=8B=A4?= =?UTF-8?q?=ED=81=AC=20=ED=85=8C=EB=A7=88=EB=A1=9C=20=EC=A0=84=EB=A9=B4=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상단 바: ☰ + 로고 + 반투명 검색창 + 프로필 메뉴(좁은 화면은 돋보기 → 전체 폭 검색) - 왼쪽 메뉴: 홈/서버 목록 + 봇이 있는 내 서버 목록, 넓은 화면 펼침(240)/접힘(72), 좁은 화면 서랍 - 홈: 서버를 원형 카드로, 서버 페이지: 앨범 페이지 형태(왼쪽 아이콘·정보, 오른쪽 재생 목록) - 검색: 필터 칩(전체/노래/동영상/Spotify) + 상위 검색결과 카드 + 구역별 목록(모두 표시) - 하단 재생바: 위쪽 빨간 진행선, 조작·시간 / 곡 정보 / hover 볼륨·재생 화면(⌃) - 재생 화면: 큰 앨범아트 + 다음 트랙 탭, 좁은 화면은 전체 화면 플레이어 - 재생/대기열/서버 로직을 hooks(usePlayer/useQueue/useServers)로 분리해 구독을 한 번만 연다 - 스낵바 알림, 파비콘, README 화면·버튼 문서 갱신 Co-Authored-By: Claude Opus 5.5 --- page/README.md | 86 +-- page/src/app/globals.css | 64 +- page/src/app/icon.svg | 1 + page/src/app/page.tsx | 151 ++--- page/src/components/ToastProvider.tsx | 10 +- page/src/components/layout/LeftSidebar.tsx | 198 ++++-- page/src/components/layout/TopNav.tsx | 244 ++++--- page/src/components/player/MainContent.tsx | 558 ++++++++-------- page/src/components/player/PlayerBar.tsx | 679 +++++--------------- page/src/components/player/PlayerPage.tsx | 181 ++++++ page/src/components/player/QueueList.tsx | 198 ++++++ page/src/components/player/QueueSidebar.tsx | 365 ----------- page/src/components/ui.tsx | 100 +++ page/src/hooks/usePlayer.ts | 330 ++++++++++ page/src/hooks/useQueue.ts | 204 ++++++ page/src/hooks/useServers.ts | 67 ++ 16 files changed, 2026 insertions(+), 1410 deletions(-) create mode 100644 page/src/app/icon.svg create mode 100644 page/src/components/player/PlayerPage.tsx create mode 100644 page/src/components/player/QueueList.tsx delete mode 100644 page/src/components/player/QueueSidebar.tsx create mode 100644 page/src/components/ui.tsx create mode 100644 page/src/hooks/usePlayer.ts create mode 100644 page/src/hooks/useQueue.ts create mode 100644 page/src/hooks/useServers.ts diff --git a/page/README.md b/page/README.md index f7eca69..f151381 100644 --- a/page/README.md +++ b/page/README.md @@ -78,65 +78,65 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브 ## 5. UI 구성 (`src/`) -- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지. `components/Providers.tsx`(세션 등), `ToastProvider.tsx`. -- `components/layout/` — `TopNav`(상단/서버선택·로그인), `LeftSidebar`(탐색). -- `components/player/` — `PlayerBar`(재생바/볼륨/탐색), `MainContent`(검색·결과), `QueueSidebar`(대기열). +디자인은 **유튜브 뮤직(웹) 다크 테마**를 그대로 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`, +보조 글자는 흰색 70%/50% 투명도(색상 토큰은 `app/globals.css`의 `--color-yt-*`). + +- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지(상단 바 · 왼쪽 메뉴 · 본문 · 하단 재생바 배치). `app/icon.svg` 파비콘. +- `components/layout/` — `TopNav`(☰·로고·검색·프로필 메뉴), `LeftSidebar`(왼쪽 메뉴: 홈/서버 목록 + 내 서버 목록). +- `components/player/` — `MainContent`(홈·서버 페이지·검색 결과), `PlayerBar`(하단 재생바), `PlayerPage`(펼친 재생 화면), + `QueueList`("다음 트랙" 목록). +- `components/ui.tsx` — 로고, 썸네일, 서버 아이콘, 로딩 표시, 재생 중 이퀄라이저. +- `components/ToastProvider.tsx` — 왼쪽 아래 스낵바 알림. +- `hooks/` — 데이터·조작 로직. 화면 여러 곳이 같은 상태를 쓰므로 `app/page.tsx`에서 한 번만 구독해 내려준다. + - `usePlayer` 현재 곡·진행·볼륨 + 재생/일시정지/다음/탐색/볼륨/단축키(SSE `player/events`) + - `useQueue` 대기열 + 순서 변경/삭제/특정 곡부터 재생(SSE `queue/events`) + - `useServers` 봇이 있는 내 서버 목록(세션 캐시) - `types/music.ts` — `DiscordServer`, `Track`/`TrackInfo`, `SearchTrack`, `SearchResults` 등 공용 도메인 타입. - `next.config.ts` — 보안 헤더(CSP: self + discord/spotify/ytimg/google 이미지 CDN만 허용, `frame-ancestors 'none'` 등). -### 5-1. 화면(모드)별 사용 흐름 +### 5-1. 화면 구성 화면은 라우트가 아니라 `app/page.tsx`의 `ViewMode` 상태로 전환된다(SPA 단일 페이지). | 화면 | 진입 방법 | 화면에 보이는 것 | 여기서 할 수 있는 일 | |------|-----------|------------------|----------------------| -| 로그인 전 | 첫 방문 | 중앙 로그인 카드 | 디스코드 로그인. 사이드바/대기열/재생바는 렌더링하지 않는다 | -| `SERVER_LIST` | 로그인 직후, 홈 버튼, "다른 서버 선택" | 봇이 있는 내 서버 카드 목록 | 서버 선택. 서버를 고르기 전에는 검색·재생이 잠겨 있다 | -| `SERVER_DETAIL` | 서버 카드 클릭 | 서버 이름/아이콘, 사용 안내 배너, 서버 ID·내 권한 | 검색 시작점. 좌측에 조작 중인 서버 표시 | -| `SEARCH_RESULT` | 검색창에 입력 후 Enter | Spotify / YouTube Music / YouTube 영상 3개 구역의 곡 리스트 행(번호·썸네일·제목/아티스트·재생시간) | 행을 눌러 대기열에 추가 | -| 대기열 패널 | (넓은 화면) 기본 펼침, 재생바 우측 대기열 버튼으로 접기/펴기 · (좁은 화면) 기본 접힘, 우하단 초록 버튼·재생바 목록 버튼으로 열기 | "지금 재생 중" + "다음 재생 목록" | 순서 변경, 삭제, 특정 곡부터 재생 | +| 로그인 전 | 첫 방문 | 가운데 로그인 안내, 우상단 "로그인" | 디스코드 로그인. 검색·재생바는 잠김/숨김 | +| `SERVER_LIST` (홈) | 로그인 직후, 왼쪽 메뉴 "서버 목록" | 봇이 있는 내 서버를 원형 카드로(유튜브 뮤직 아티스트 줄 형태) | 서버 선택 | +| `SERVER_DETAIL` (서버 페이지) | 서버 카드/왼쪽 메뉴의 서버, 로고·"홈" | 유튜브 뮤직 앨범 페이지 형태: 왼쪽 서버 아이콘·이름·권한·사용 안내, 오른쪽 "재생 목록"(지금 재생 중 + 다음 트랙) | 검색 시작, 대기열 조작 | +| `SEARCH_RESULT` | 검색창 Enter | 필터 칩(전체/노래/동영상/Spotify), 상위 검색결과 카드, 구역별 곡 목록(전체 탭은 5곡씩 + "모두 표시") | 곡을 눌러 대기열에 추가 | +| 재생 화면 | 재생바의 곡 정보·⌃ 버튼, 서버 페이지의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + "다음 트랙" 탭 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 | 대기열 조작, (좁은 화면) 재생 조작 | + +- 재생바는 유튜브 뮤직처럼 **재생 중인 곡이 있을 때만** 나타난다. +- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침(240px)/접힘(아이콘만 72px), 좁은 화면은 ☰ 로 서랍을 연다. +- 좁은 화면 상단 바는 검색창 대신 돋보기 버튼 → 누르면 바 전체가 검색창이 된다. ### 5-2. 버튼·조작 동작 정리 | 위치 | 요소 | 동작 | 비활성 조건 | |------|------|------|-------------| -| 상단 | 홈 | 서버를 고른 상태면 서버 상세로, 아니면 서버 목록으로. 검색어 초기화 | 로그아웃 상태에선 숨김 | -| 상단 | 검색창 | Enter 로 검색 실행 → `SEARCH_RESULT` | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) | -| 상단 | 프로필/로그아웃 | 세션 종료 | — | -| 본문 | 서버 카드 | 서버 선택 → 서버 상세 | — | -| 본문 | 곡 행(행 전체가 버튼) | `POST /api/player/play` → 대기열 추가. 성공/실패를 토스트로 알림. 넓은 화면은 hover 시 번호 자리에 재생 아이콘이, 좁은 화면은 썸네일에 재생 아이콘이 항상 보인다 | 서버 미선택·미로그인 | -| 재생바 | 재생/일시정지 | `POST /api/player/pause` (낙관적 UI, 실패 시 롤백) | 봇 세션 없음/재생 중 아님 | -| 재생바 | 다음 곡 | `POST /api/player/skip` | 〃 | +| 상단 | ☰ | 왼쪽 메뉴 펼침/접힘(좁은 화면은 서랍) | — | +| 상단 | 로고 | 서버를 고른 상태면 서버 페이지로, 아니면 홈으로 | — | +| 상단 | 검색창 | Enter 로 검색 → `SEARCH_RESULT`. ✕ 로 검색어 지우기 | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) | +| 상단 | 프로필 사진 | 계정 메뉴(이름 + 로그아웃). 바깥 클릭/Esc 로 닫힘 | — | +| 왼쪽 메뉴 | 홈 / 서버 목록 / 내 서버 | 서버 페이지 / 서버 고르기 / 해당 서버로 바로 전환 | — | +| 서버 페이지 | 노래 검색 | 상단 검색창에 포커스(좁은 화면은 검색 모드로 전환) | — | +| 서버 페이지 | 목록 아이콘 | 재생 화면 열기 | — | +| 검색 | 필터 칩 | 해당 출처 결과만 전부 보기 | 결과 0건인 칩 | +| 검색 | 곡 행(행 전체가 버튼) / 상위 검색결과 "재생" | `POST /api/player/play` → 대기열 추가(재생 중이 아니면 바로 재생). 스낵바로 결과 알림. 썸네일 위 재생 아이콘은 마우스면 hover 때, 터치 화면이면 항상 | 서버 미선택·미로그인 | +| 재생바 | 진행바(위쪽 가장자리 빨간 선) | hover 시 굵어지고 손잡이 표시. 드래그/클릭/키보드로 탐색 → `POST /api/player/seek` | 봇 세션 없음/재생 중 아님 | +| 재생바 | 재생/일시정지 · 다음 곡 | `POST /api/player/pause`(낙관적 UI, 실패 시 롤백) · `POST /api/player/skip` | 〃 | | 재생바 | 이전 곡 | 미지원(봇 API 에 없음). 이유를 툴팁으로 표시하고 비활성 | 항상 | -| 재생바 | 진행바 | 드래그/클릭으로 탐색 → `POST /api/player/seek`. 좁은 화면에서는 재생바 상단에 전체 폭으로 배치 | 〃 | -| 재생바 | 스피커 아이콘 | 음소거/해제 토글(해제 시 직전 볼륨 복원) | 〃 | -| 재생바 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 | -| 재생바 | 목록 아이콘(컨트롤 옆, 좁은 화면) | 대기열 슬라이드 패널 열기/닫기 | — | -| 재생바 | 목록 아이콘(우측 볼륨 옆, 넓은 화면) | 우측 대기열 컬럼 접기/펴기. 펼쳐져 있으면 초록색으로 표시 | — | -| 대기열 | 곡(제목 영역) | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다 | 첫 곡은 이미 다음 곡이라 동작 없음 | -| 대기열 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (좁은 화면 전용, 드래그 대체) | 맨 위/맨 아래 | -| 대기열 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지) | — | -| 대기열 | 드래그 핸들 | 드래그로 순서 변경(넓은 화면 전용) | — | +| 재생바 | 곡 정보 / ⌃ | 재생 화면 열기·닫기 | — | +| 재생바 | 스피커 아이콘 | 클릭 = 음소거/해제(해제 시 직전 볼륨 복원). hover 시 왼쪽에 볼륨 슬라이더가 펼쳐짐 | 봇 세션 없음/재생 중 아님 | +| 재생바·재생 화면 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 | +| 다음 트랙 | 곡 | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다(첫 곡이면 다음 곡으로 넘기기만) | — | +| 다음 트랙 | 손잡이 드래그 | 순서 변경(마우스 전용, hover 시 표시) | — | +| 다음 트랙 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (터치 화면 전용, 드래그 대체) | 맨 위/맨 아래 | +| 다음 트랙 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지). 마우스면 hover 때, 터치 화면이면 항상 표시 | — | +| 재생 화면 | ⌄ / Esc | 재생 화면 닫기 | — | | 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | 입력창 포커스 중에는 가로채지 않음 | -### 5-3. 다른 음악 서비스와 비교해 맞춘 점 - -스포티파이 웹 플레이어 / 유튜브 뮤직 / 애플 뮤직에서 공통으로 쓰이는 관행을 기준으로 삼았다. - -| 관행 | 예전 | 현재 | -|------|------|------| -| 곡 검색 결과 표시 | 그리드 카드 | 리스트 행(번호·썸네일·제목/아티스트·재생시간). 그리드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태이고, 개별 곡은 두 서비스 모두 행 형식이다 | -| 대기열 접기/펴기 | 넓은 화면에서 항상 고정 | 재생바 우측 대기열 버튼으로 접고 펼 수 있음(스포티파이와 같은 위치) | -| 좁은 화면 대응 | 3열 고정이라 모바일에서 본문이 눌려 사용 불가 | 단일 컬럼 + 대기열 슬라이드 패널 | -| 대기열 상단에 "지금 재생 중" | 없음(다음 곡만) | 현재 곡을 강조해 표시 | -| 대기열 곡 클릭 → 그 곡부터 재생 | 없음 | 지원(앞 곡들은 빠짐 — 스포티파이 대기열과 동일) | -| 스피커 아이콘 클릭 = 음소거 | 장식 아이콘 | 토글 버튼 | -| 키보드 단축키(Space/M/화살표) | 없음 | 지원 | -| 재생시간 표기 | 없음 | 검색 결과 행 오른쪽에 표시 | -| 터치 조작 크기(44px 내외) | 재생 32px·삭제 24px·진행바 1px | 44~48px, 진행바 히트영역 확대 | -| hover 없이도 조작 가능 | 삭제·재생 버튼이 hover 시에만 노출 | 상시 노출 | - -미지원(봇 API 한계): 이전 곡, 반복 재생, 특정 위치로의 셔플 재생. 필요하면 봇 쪽 액션 추가가 선행돼야 한다. +미지원(봇 API 한계): 이전 곡, 반복 재생, 셔플, 좋아요/가사. 유튜브 뮤직에 있는 이 버튼들은 동작하지 않는 가짜 버튼이 되므로 넣지 않았다. --- diff --git a/page/src/app/globals.css b/page/src/app/globals.css index 8f4a3a3..0cb5684 100644 --- a/page/src/app/globals.css +++ b/page/src/app/globals.css @@ -1,10 +1,25 @@ -/* Tailwind v4의 핵심! 이 한 줄로 모든 유틸리티 클래스를 불러옵니다. */ @import "tailwindcss"; -/* 다크 모드 기반의 플레이어이므로 배경을 기본적으로 어둡게 세팅합니다. */ +/* + * 유튜브 뮤직(웹) 다크 테마 색상. + * - 배경 #030303, 재생바/메뉴 #212121, 강조 빨강 #ff0000 + * - 보조 글자는 흰색 70%/50% 투명도(유튜브 뮤직과 같은 방식) + */ @theme { - --color-background: #171717; /* neutral-900 */ + --color-background: #030303; --color-foreground: #ffffff; + --color-yt-bg: #030303; + --color-yt-raised: #212121; + --color-yt-menu: #282828; + --color-yt-red: #ff0000; + --color-yt-chip: rgb(255 255 255 / 0.1); + --color-yt-chip-hover: rgb(255 255 255 / 0.2); + --color-yt-line: rgb(255 255 255 / 0.1); + --font-sans: Roboto, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", Arial, sans-serif; +} + +html { + color-scheme: dark; } body { @@ -12,19 +27,54 @@ body { color: var(--color-foreground); margin: 0; padding: 0; + -webkit-font-smoothing: antialiased; } -/* 스크롤바 예쁘게 숨기기 (선택 사항) */ +/* 유튜브 뮤직처럼 얇고 어두운 스크롤바 */ +* { + scrollbar-width: thin; + scrollbar-color: rgb(255 255 255 / 0.2) transparent; +} ::-webkit-scrollbar { width: 8px; + height: 8px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background: #3f3f46; + background: rgb(255 255 255 / 0.2); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { - background: #52525b; -} \ No newline at end of file + background: rgb(255 255 255 / 0.3); +} + +/* 가로 스크롤 칩 줄은 스크롤바를 숨긴다 */ +.no-scrollbar { + scrollbar-width: none; +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} + +/* 재생 중 표시(이퀄라이저 막대) */ +@keyframes yt-eq { + 0%, 100% { transform: scaleY(0.3); } + 50% { transform: scaleY(1); } +} +.yt-eq > span { + display: block; + width: 3px; + height: 14px; + background: currentColor; + transform-origin: bottom; + animation: yt-eq 0.9s ease-in-out infinite; +} +.yt-eq > span:nth-child(2) { animation-delay: -0.3s; } +.yt-eq > span:nth-child(3) { animation-delay: -0.6s; } +.yt-eq.paused > span { animation-play-state: paused; } + +@media (prefers-reduced-motion: reduce) { + .yt-eq > span { animation: none; transform: scaleY(0.6); } +} diff --git a/page/src/app/icon.svg b/page/src/app/icon.svg new file mode 100644 index 0000000..0cccbde --- /dev/null +++ b/page/src/app/icon.svg @@ -0,0 +1 @@ + diff --git a/page/src/app/page.tsx b/page/src/app/page.tsx index 637adb3..e99083f 100644 --- a/page/src/app/page.tsx +++ b/page/src/app/page.tsx @@ -2,151 +2,132 @@ import { useCallback, useState } from "react"; import { useSession } from "next-auth/react"; -import { ListMusic, X } from "lucide-react"; import TopNav from "@/components/layout/TopNav"; import LeftSidebar from "@/components/layout/LeftSidebar"; import MainContent from "@/components/player/MainContent"; -import QueueSidebar from "@/components/player/QueueSidebar"; import PlayerBar from "@/components/player/PlayerBar"; -import type { DiscordServer, Track } from "@/types/music"; +import PlayerPage from "@/components/player/PlayerPage"; +import { usePlayer } from "@/hooks/usePlayer"; +import { useQueue } from "@/hooks/useQueue"; +import { useServers } from "@/hooks/useServers"; +import type { DiscordServer } from "@/types/music"; // 화면 모드 타입 정의 export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT"; +/** + * 유튜브 뮤직(웹) 구조: + * 상단 바(☰ 로고 · 검색 · 프로필) + * [왼쪽 메뉴] [본문] ← 재생 화면(⌃)은 본문 위를 덮는다 + * 하단 재생바 + */ export default function MusicPlayerLayout() { const { status } = useSession(); const [viewMode, setViewMode] = useState("SERVER_LIST"); const [selectedServer, setSelectedServer] = useState(null); const [searchQuery, setSearchQuery] = useState(""); - // 대기열 표시 상태는 화면 크기별로 기본값이 달라서 두 개로 나눠 둔다. - // (창 크기를 읽어 한 상태로 합치면 SSR·하이드레이션이 어긋난다) - // · 좁은 화면: 기본 접힘 → 버튼으로 슬라이드 패널을 연다 - // · 넓은 화면: 기본 펼침 → 재생바의 대기열 버튼으로 접을 수 있다 - const [isQueueOpen, setIsQueueOpen] = useState(false); - const [isQueueDocked, setIsQueueDocked] = useState(true); - // 재생바가 이미 폴링하는 현재 곡을 대기열 패널과 공유한다(같은 API 를 두 번 부르지 않기 위함). - const [nowPlaying, setNowPlaying] = useState(null); - const handleNowPlayingChange = useCallback((t: Track | null) => setNowPlaying(t), []); + // 왼쪽 메뉴: 넓은 화면은 펼침/접힘, 좁은 화면은 서랍 + const [guideExpanded, setGuideExpanded] = useState(true); + const [drawerOpen, setDrawerOpen] = useState(false); + const [isPlayerOpen, setIsPlayerOpen] = useState(false); + + const { servers, isFetching } = useServers(); + // 재생 상태·대기열은 재생바, 재생 화면, 서버 페이지가 같이 쓰므로 여기서 한 번만 구독한다. + const player = usePlayer(selectedServer); + const queue = useQueue(selectedServer); const isAuthed = status === "authenticated"; - // 서버를 고르기 전에는 재생/대기열이 의미가 없어, 화면을 차지하지 않게 숨긴다. + // 서버를 고르기 전에는 재생이 의미가 없고, 유튜브 뮤직처럼 곡이 있을 때만 재생바를 띄운다. const showPlayback = isAuthed && !!selectedServer; + const showPlayerBar = showPlayback && !!player.track; const handleHome = () => { + setIsPlayerOpen(false); if (selectedServer) { setViewMode("SERVER_DETAIL"); } else { setViewMode("SERVER_LIST"); - setSelectedServer(null); setSearchQuery(""); } }; const handleSearch = (query: string) => { + setIsPlayerOpen(false); setSearchQuery(query); setViewMode("SEARCH_RESULT"); }; const handleSelectServer = (server: DiscordServer) => { + setIsPlayerOpen(false); setSelectedServer(server); setViewMode("SERVER_DETAIL"); }; const handleBackToServers = () => { + setIsPlayerOpen(false); setViewMode("SERVER_LIST"); setSelectedServer(null); setSearchQuery(""); - setIsQueueOpen(false); }; + const handleToggleGuide = () => { + // 넓은 화면이면 펼침/접힘, 좁은 화면이면 서랍 열기 + if (window.matchMedia("(min-width: 1024px)").matches) setGuideExpanded((v) => !v); + else setDrawerOpen(true); + }; + + const closePlayer = useCallback(() => setIsPlayerOpen(false), []); + return ( -
+
-
- {/* 좌측 사이드바: 보조 정보라 넓은 화면에서만 노출한다(모바일 공간 절약). */} - {showPlayback && ( - - )} - - + setDrawerOpen(false)} + isAuthed={isAuthed} viewMode={viewMode} - setViewMode={setViewMode} + servers={servers} selectedServer={selectedServer} - setSelectedServer={setSelectedServer} - searchQuery={searchQuery} - setSearchQuery={setSearchQuery} + onHome={handleHome} + onServers={handleBackToServers} onSelectServer={handleSelectServer} /> - {showPlayback && ( - <> - {/* 모바일에서 대기열이 열렸을 때 뒤쪽을 덮는 배경(탭하면 닫힘) */} - {isQueueOpen && ( -
- {showPlayback && ( + {showPlayerBar && ( setIsQueueOpen((v) => !v)} - isQueueOpen={isQueueOpen} - onToggleDock={() => setIsQueueDocked((v) => !v)} - isQueueDocked={isQueueDocked} - onNowPlayingChange={handleNowPlayingChange} + player={player} + isPlayerOpen={isPlayerOpen} + onTogglePlayer={() => setIsPlayerOpen((v) => !v)} /> )} - - {/* 모바일 전용 대기열 열기 버튼: 플레이어바에도 있지만, 재생 전에도 접근할 수 있게 둔다. */} - {showPlayback && !isQueueOpen && ( - - )} - - {/* 모바일 패널 닫기 버튼(패널 위에 떠 있음) */} - {showPlayback && isQueueOpen && ( - - )}
); } diff --git a/page/src/components/ToastProvider.tsx b/page/src/components/ToastProvider.tsx index 74e555d..c02ce9e 100644 --- a/page/src/components/ToastProvider.tsx +++ b/page/src/components/ToastProvider.tsx @@ -29,13 +29,13 @@ export function ToastProvider({ children }: { children: ReactNode }) { {children} - {/* 화면 최상단에 떠있는 실제 토스트 UI */} + {/* 유튜브 뮤직 스낵바: 왼쪽 아래(재생바 위)에 뜨는 어두운 알림 */} {toast && ( -
+ {toast.type === "error" && } {toast.message}
)} diff --git a/page/src/components/layout/LeftSidebar.tsx b/page/src/components/layout/LeftSidebar.tsx index 326d0f9..35cbba2 100644 --- a/page/src/components/layout/LeftSidebar.tsx +++ b/page/src/components/layout/LeftSidebar.tsx @@ -1,61 +1,165 @@ "use client"; -import { ChevronLeft, Info, Server } from "lucide-react"; +import { Home, Library, Menu } from "lucide-react"; import type { DiscordServer } from "@/types/music"; +import type { ViewMode } from "@/app/page"; +import { Logo, ServerAvatar } from "@/components/ui"; +import { permissionLabel } from "@/hooks/useServers"; interface LeftSidebarProps { + /** 넓은 화면에서 펼친 메뉴(240px)인지, 아이콘만 있는 좁은 메뉴(72px)인지 */ + expanded: boolean; + /** 좁은 화면에서 서랍처럼 열린 상태 */ + drawerOpen: boolean; + onCloseDrawer: () => void; + isAuthed: boolean; + viewMode: ViewMode; + servers: DiscordServer[]; selectedServer: DiscordServer | null; - onBackToServers: () => void; + onHome: () => void; + onServers: () => void; + onSelectServer: (server: DiscordServer) => void; } /** - * 좌측 보조 패널. 예전에는 안내 문구만 있는 죽은 공간이었어서, - * "지금 어느 서버를 조작 중인지"와 서버 전환 동선을 넣어 실제로 쓰이게 했다. - * 좁은 화면에서는 숨긴다(같은 정보가 본문/플레이어에 이미 있다). + * 유튜브 뮤직 왼쪽 메뉴(가이드). + * - 위: 홈 / 서버 목록(유튜브 뮤직의 "보관함" 자리) + * - 아래: 재생목록 자리에 "봇이 있는 내 서버" 목록 → 바로 서버 전환 + * 넓은 화면은 ☰ 로 펼침/접힘(아이콘만), 좁은 화면은 ☰ 로 서랍을 연다. */ -export default function LeftSidebar({ selectedServer, onBackToServers }: LeftSidebarProps) { +export default function LeftSidebar({ + expanded, + drawerOpen, + onCloseDrawer, + isAuthed, + viewMode, + servers, + selectedServer, + onHome, + onServers, + onSelectServer, +}: LeftSidebarProps) { + const nav = [ + { key: "home", label: "홈", icon: Home, active: viewMode === "SERVER_DETAIL", onClick: onHome }, + { key: "servers", label: "서버 목록", icon: Library, active: viewMode === "SERVER_LIST", onClick: onServers }, + ]; + + /** 펼친 메뉴 본문(넓은 화면 펼침 + 좁은 화면 서랍에서 같이 쓴다) */ + const full = (afterClick?: () => void) => ( +
+
    + {nav.map(({ key, label, icon: Icon, active, onClick }) => ( +
  • + +
  • + ))} +
+ + {isAuthed && ( + <> +
+

내 서버

+
    + {servers.length === 0 ? ( +
  • + 음악 봇이 있는 서버가 여기에 표시됩니다. +
  • + ) : ( + servers.map((server) => { + const active = selectedServer?.id === server.id; + return ( +
  • + +
  • + ); + }) + )} +
+ + )} +
+ ); + return ( - -
-

- 사용 방법 -

-

- 디스코드 음성 채널에 먼저 들어간 뒤, 위 검색창에서 노래를 찾아 재생 버튼을 누르세요. -

-
- + {/* 좁은 화면: 서랍 메뉴 */} + {drawerOpen && ( +
+ + +
+
{full(onCloseDrawer)}
+ +
+ )} + ); } diff --git a/page/src/components/layout/TopNav.tsx b/page/src/components/layout/TopNav.tsx index 5a21b77..1307773 100644 --- a/page/src/components/layout/TopNav.tsx +++ b/page/src/components/layout/TopNav.tsx @@ -1,33 +1,31 @@ "use client"; -import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react"; +import { ArrowLeft, LogOut, Menu, Search, X } from "lucide-react"; import { signIn, signOut, useSession } from "next-auth/react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { DiscordServer } from "@/types/music"; +import { Logo } from "@/components/ui"; + +/** 다른 화면(서버 페이지의 "노래 검색" 버튼 등)에서 검색창을 열 때 쓰는 이벤트 이름 */ +export const FOCUS_SEARCH_EVENT = "musicbot:focus-search"; interface TopNavProps { onSearch: (query: string) => void; onHome: () => void; + onToggleGuide: () => 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"; +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 - + ); } return ( {(user.name ?? "?").trim().charAt(0).toUpperCase()} @@ -35,22 +33,55 @@ function Avatar({ ); } -export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) { +/** + * 유튜브 뮤직 상단 바: [☰ 로고] [검색창] ......... [프로필] + * 좁은 화면에서는 검색창 대신 돋보기 버튼을 두고, 누르면 바 전체가 검색창으로 바뀐다(유튜브 뮤직 모바일과 같은 방식). + */ +export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer }: TopNavProps) { const { data: session, status } = useSession(); const [term, setTerm] = useState(""); + const [mobileSearch, setMobileSearch] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const inputRef = useRef(null); + const menuRef = useRef(null); const isAuthed = status === "authenticated"; const canSearch = isAuthed && !!selectedServer; const submitSearch = () => { - if (canSearch && term.trim()) onSearch(term.trim()); + if (canSearch && term.trim()) { + onSearch(term.trim()); + inputRef.current?.blur(); + setMobileSearch(false); + } }; - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") submitSearch(); - }; + // 서버 페이지의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환) + useEffect(() => { + const focus = () => { + setMobileSearch(true); + requestAnimationFrame(() => inputRef.current?.focus()); + }; + window.addEventListener(FOCUS_SEARCH_EVENT, focus); + return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus); + }, []); - const handleHomeClick = () => { + // 프로필 메뉴: 바깥을 누르거나 Esc 로 닫는다. + useEffect(() => { + if (!menuOpen) return; + const onDown = (e: MouseEvent) => { + if (!menuRef.current?.contains(e.target as Node)) setMenuOpen(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]); + + const handleLogoClick = () => { setTerm(""); onHome(); }; @@ -60,76 +91,141 @@ export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps ? "로그인하면 검색할 수 있어요" : !selectedServer ? "먼저 서버를 선택해주세요" - : "노래 제목이나 유튜브·스포티파이 주소"; + : "노래, 아티스트, 유튜브·스포티파이 주소 검색"; return ( -
-