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:
@@ -159,7 +159,9 @@ Export → 받은 `cookies.txt`를 `/root/bot/db/ytcookie.txt`로 복사.
|
|||||||
봇은 처리 후 결과를 `"<action-key>:<requestId>"` 키에 `setex`(예: `player:play:<reqId>`, `queue:list:<reqId>`).
|
봇은 처리 후 결과를 `"<action-key>:<requestId>"` 키에 `setex`(예: `player:play:<reqId>`, `queue:list:<reqId>`).
|
||||||
사이트는 그 키를 짧게 폴링해 결과를 받는다.
|
사이트는 그 키를 짧게 폴링해 결과를 받는다.
|
||||||
- action 예: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
|
- action 예: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
|
||||||
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`.
|
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`,
|
||||||
|
`queue_shuffle`(다음 트랙 섞기), `player_recommend`(자동재생 토글 — 재생 중이 아니어도 바꿀 수 있음).
|
||||||
|
- `player_now` 응답에는 대시보드 표시용 `recommend`(자동재생 상태)와 `voiceChannelName`(봇이 있는 음성채널)도 들어 있다.
|
||||||
- **bot → site (실시간 상태)**: 상태가 바뀌면 봇이 `bot-site` 채널에 `{event, guildId}`를 publish
|
- **bot → site (실시간 상태)**: 상태가 바뀌면 봇이 `bot-site` 채널에 `{event, guildId}`를 publish
|
||||||
(`player_update`, `queue_update`). 사이트는 SSE로 브라우저에 흘려보낸다.
|
(`player_update`, `queue_update`). 사이트는 SSE로 브라우저에 흘려보낸다.
|
||||||
- 활성화: `bot/.env`의 `REDIS=true`, `REDIS_HOST`, `REDIS_PORT`.
|
- 활성화: `bot/.env`의 `REDIS=true`, `REDIS_HOST`, `REDIS_PORT`.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { channelJoin } from "../commands/join";
|
|||||||
import { GuildPlayer } from "./GuildPlayer";
|
import { GuildPlayer } from "./GuildPlayer";
|
||||||
import { Guild, VoiceChannel } from "discord.js";
|
import { Guild, VoiceChannel } from "discord.js";
|
||||||
import { SongItem } from "../types/Track";
|
import { SongItem } from "../types/Track";
|
||||||
|
import { DB } from "../utils/Database";
|
||||||
|
import { buttonRecommend } from "../utils/music/Button";
|
||||||
|
|
||||||
type SubAction =
|
type SubAction =
|
||||||
"search" |
|
"search" |
|
||||||
@@ -21,7 +23,9 @@ type SubAction =
|
|||||||
"player_volume" |
|
"player_volume" |
|
||||||
"queue_list" |
|
"queue_list" |
|
||||||
"queue_set" |
|
"queue_set" |
|
||||||
"queue_remove";
|
"queue_remove" |
|
||||||
|
"queue_shuffle" |
|
||||||
|
"player_recommend";
|
||||||
|
|
||||||
export const RedisClient = () => {
|
export const RedisClient = () => {
|
||||||
if (Config.redis.state) return new RedisClientClass();
|
if (Config.redis.state) return new RedisClientClass();
|
||||||
@@ -141,7 +145,10 @@ class RedisClientClass {
|
|||||||
isPaused: player?.isPaused,
|
isPaused: player?.isPaused,
|
||||||
position: player?.position,
|
position: player?.position,
|
||||||
volume: player?.volume,
|
volume: player?.volume,
|
||||||
track: player?.nowTrack ?? null
|
track: player?.nowTrack ?? null,
|
||||||
|
// 웹 대시보드 표시용: 자동재생 상태, 봇이 들어가 있는 음성채널 이름
|
||||||
|
recommend: player ? player.isRecommend : (DB.guild.get(data.serverId)?.options.recommend ?? false),
|
||||||
|
voiceChannelName: player ? (nowGuild.channels.cache.get(player.voiceChannelId)?.name ?? null) : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (data.action === "queue_list") {
|
if (data.action === "queue_list") {
|
||||||
@@ -194,6 +201,39 @@ class RedisClientClass {
|
|||||||
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
await this.reply(resultKey, 60, JSON.stringify({ success: true, removedTrack }));
|
||||||
context.player.setMsg();
|
context.player.setMsg();
|
||||||
}
|
}
|
||||||
|
if (data.action === "queue_shuffle") {
|
||||||
|
const resultKey = `queue:shuffle:${data.requestId}`;
|
||||||
|
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||||
|
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
|
const context = await this.getContext(data.serverId, resultKey, data.userId);
|
||||||
|
if (!context.ok) return;
|
||||||
|
if (context.player.queue.length < 3) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "섞을 곡이 2곡 이상 있어야 합니다." }));
|
||||||
|
context.player.setShuffle();
|
||||||
|
await this.reply(resultKey, 60, JSON.stringify({ success: true }));
|
||||||
|
}
|
||||||
|
if (data.action === "player_recommend") {
|
||||||
|
// 자동재생 토글. 재생 중이 아니어도 바꿀 수 있어야 하므로 음성채널 접속(getContext)을 거치지 않는다.
|
||||||
|
const resultKey = `player:recommend:${data.requestId}`;
|
||||||
|
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||||
|
if (!data.userId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
|
const recGuild = await getGuildById(data.serverId);
|
||||||
|
if (!recGuild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||||
|
if (!(await this.isMember(recGuild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
|
||||||
|
const player = lavalinkManager.getPlayer(recGuild.id);
|
||||||
|
if (player) {
|
||||||
|
// 재생 중이면 봇과 같은 음성채널에 있는 사람만 바꿀 수 있다(다른 조작과 같은 규칙).
|
||||||
|
const voiceChannel = await getVoiceChannelById(recGuild, data.userId);
|
||||||
|
if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
|
||||||
|
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 조작할 수 있습니다." }));
|
||||||
|
}
|
||||||
|
player.setRecommend();
|
||||||
|
} else {
|
||||||
|
await buttonRecommend(recGuild);
|
||||||
|
}
|
||||||
|
const recommend = DB.guild.get(recGuild.id)?.options.recommend ?? false;
|
||||||
|
await this.reply(resultKey, 60, JSON.stringify({ success: true, recommend }));
|
||||||
|
this.publishState("player_update", { guildId: recGuild.id });
|
||||||
|
}
|
||||||
if (data.action === "player_paused") {
|
if (data.action === "player_paused") {
|
||||||
const resultKey = `player:paused:${data.requestId}`;
|
const resultKey = `player:paused:${data.requestId}`;
|
||||||
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
if (!data.serverId) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const buttonInteraction = (interaction: ButtonInteraction, args: string[]
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonRecommend = async (guild: Guild) => {
|
export const buttonRecommend = async (guild: Guild) => {
|
||||||
const gdb = DB.guild.get(guild.id);
|
const gdb = DB.guild.get(guild.id);
|
||||||
const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false;
|
const change = gdb ? DB.guild.update({...gdb, options: { ...gdb.options, recommend: !gdb.options.recommend }}) : false;
|
||||||
if (!change) return;
|
if (!change) return;
|
||||||
|
|||||||
108
page/README.md
108
page/README.md
@@ -50,7 +50,7 @@ site: 그 키를 짧게 폴링(100ms→최대400ms 백오프, 기본 3초 타임
|
|||||||
```
|
```
|
||||||
- `requestId`는 CSPRNG(`randomUUID`). 결과 키는 읽은 즉시 삭제 + TTL 보험.
|
- `requestId`는 CSPRNG(`randomUUID`). 결과 키는 읽은 즉시 삭제 + TTL 보험.
|
||||||
- action: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
|
- action: `search`, `player_play`, `player_playlist`, `player_now`, `player_pause`, `player_skip`,
|
||||||
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`.
|
`player_seek`, `player_volume`, `queue_list`, `queue_set`, `queue_remove`, `queue_shuffle`, `player_recommend`.
|
||||||
|
|
||||||
### 3-2. 실시간 상태 (bot → site, SSE) — `src/lib/sse.ts`의 `botEventStream`
|
### 3-2. 실시간 상태 (bot → site, SSE) — `src/lib/sse.ts`의 `botEventStream`
|
||||||
```
|
```
|
||||||
@@ -66,8 +66,9 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
|
|||||||
|
|
||||||
모든 라우트는 세션 가드(`requireSession`)를 거치고 `{ success, … }` 형태로 응답한다.
|
모든 라우트는 세션 가드(`requireSession`)를 거치고 `{ success, … }` 형태로 응답한다.
|
||||||
|
|
||||||
- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `events`(SSE)
|
- **player**: `play`, `pause`, `skip`, `seek`, `volume`, `playlist`, `now`, `autoplay`(자동재생 토글), `events`(SSE)
|
||||||
- **queue**: `list`, `set`, `remove`, `events`(SSE)
|
- **queue**: `list`, `set`, `remove`, `shuffle`(다음 트랙 섞기), `events`(SSE)
|
||||||
|
- `play`/`playlist`는 음성채널 접속 + 곡·재생목록 해석까지 기다려야 해서 응답 대기 시간을 15초로 둔다.
|
||||||
- **servers**: 로그인 유저의 디스코드 서버 목록(봇이 있는 서버로 필터링)
|
- **servers**: 로그인 유저의 디스코드 서버 목록(봇이 있는 서버로 필터링)
|
||||||
- **search**: 검색(봇의 `search` action → Spotify/YT Music/YT Video 결과)
|
- **search**: 검색(봇의 `search` action → Spotify/YT Music/YT Video 결과)
|
||||||
- **auth**: `[...nextauth]`(Discord OAuth)
|
- **auth**: `[...nextauth]`(Discord OAuth)
|
||||||
@@ -78,65 +79,66 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
|
|||||||
|
|
||||||
## 5. UI 구성 (`src/`)
|
## 5. UI 구성 (`src/`)
|
||||||
|
|
||||||
디자인은 **유튜브 뮤직(웹) 다크 테마**를 그대로 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`,
|
디자인은 **유튜브 뮤직(웹) 다크 테마**를 따른다: 배경 `#030303`, 재생바/메뉴 `#212121`, 강조 빨강 `#ff0000`,
|
||||||
보조 글자는 흰색 70%/50% 투명도(색상 토큰은 `app/globals.css`의 `--color-yt-*`).
|
보조 글자는 흰색 70%/50% 투명도(색상 토큰은 `app/globals.css`의 `--color-yt-*`).
|
||||||
|
|
||||||
- `app/layout.tsx`, `app/page.tsx` — 루트 레이아웃/페이지(상단 바 · 왼쪽 메뉴 · 본문 · 하단 재생바 배치). `app/icon.svg` 파비콘.
|
### 5-1. 페이지(주소)
|
||||||
- `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. 화면 구성
|
| 주소 | 내용 | 접근 규칙 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `/` | 바로 이동만 한다 | 로그인 안 했으면 `/login`, 했으면 `/servers` |
|
||||||
|
| `/login` | 로그인 전용 페이지(Discord 로그인 버튼 + 사용 3단계 안내, 로그인 오류 표시) | 이미 로그인했으면 `callbackUrl`(없으면 `/servers`)로 이동 |
|
||||||
|
| `/servers` | 서버 선택 전용 페이지(봇이 있는 내 서버, 최근 사용 서버를 맨 앞에 "최근" 표시, 새로고침, 8개 넘으면 이름 검색) | 로그인 필수 |
|
||||||
|
| `/servers/[serverId]` | 유튜브 뮤직 형태의 봇 플레이어. 검색하면 `?q=검색어`가 붙어 뒤로가기로 홈↔검색을 오간다 | 로그인 필수. 내 서버 목록에 없는 서버면 "열 수 없어요" 안내 후 서버 선택으로 |
|
||||||
|
|
||||||
화면은 라우트가 아니라 `app/page.tsx`의 `ViewMode` 상태로 전환된다(SPA 단일 페이지).
|
- 로그인 검사는 서버 컴포넌트에서 한다(`lib/auth.ts`의 `requirePageSession`). 돌아올 주소(`callbackUrl`)는 같은 사이트 경로만 허용한다.
|
||||||
|
- NextAuth 로그인/오류 화면도 `/login`으로 돌린다(`authOptions.pages`).
|
||||||
|
- 쓰다가 세션이 끊기면 클라이언트에서 `/login`으로 보낸다.
|
||||||
|
|
||||||
| 화면 | 진입 방법 | 화면에 보이는 것 | 여기서 할 수 있는 일 |
|
### 5-2. 파일 구성
|
||||||
|------|-----------|------------------|----------------------|
|
|
||||||
| 로그인 전 | 첫 방문 | 가운데 로그인 안내, 우상단 "로그인" | 디스코드 로그인. 검색·재생바는 잠김/숨김 |
|
|
||||||
| `SERVER_LIST` (홈) | 로그인 직후, 왼쪽 메뉴 "서버 목록" | 봇이 있는 내 서버를 원형 카드로(유튜브 뮤직 아티스트 줄 형태) | 서버 선택 |
|
|
||||||
| `SERVER_DETAIL` (서버 페이지) | 서버 카드/왼쪽 메뉴의 서버, 로고·"홈" | 유튜브 뮤직 앨범 페이지 형태: 왼쪽 서버 아이콘·이름·권한·사용 안내, 오른쪽 "재생 목록"(지금 재생 중 + 다음 트랙) | 검색 시작, 대기열 조작 |
|
|
||||||
| `SEARCH_RESULT` | 검색창 Enter | 필터 칩(전체/노래/동영상/Spotify), 상위 검색결과 카드, 구역별 곡 목록(전체 탭은 5곡씩 + "모두 표시") | 곡을 눌러 대기열에 추가 |
|
|
||||||
| 재생 화면 | 재생바의 곡 정보·⌃ 버튼, 서버 페이지의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + "다음 트랙" 탭 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 | 대기열 조작, (좁은 화면) 재생 조작 |
|
|
||||||
|
|
||||||
- 재생바는 유튜브 뮤직처럼 **재생 중인 곡이 있을 때만** 나타난다.
|
- `app/login`, `app/servers`, `app/servers/[serverId]` — 위 페이지(서버 컴포넌트, 로그인 검사 후 화면 컴포넌트 렌더).
|
||||||
- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침(240px)/접힘(아이콘만 72px), 좁은 화면은 ☰ 로 서랍을 연다.
|
- `components/pages/` — `LoginView`, `ServerPicker`, `PlayerApp`(플레이어 전체 배치·검색 주소 관리·탭 제목).
|
||||||
- 좁은 화면 상단 바는 검색창 대신 돋보기 버튼 → 누르면 바 전체가 검색창이 된다.
|
- `components/layout/` — `TopNav`(☰·로고·검색·최근 검색어), `LeftSidebar`(홈/검색/서버 변경 + 조작 중인 서버·음성채널),
|
||||||
|
`AccountMenu`(프로필 → 서버 변경·로그아웃만).
|
||||||
|
- `components/player/` — `MainContent`(서버 홈·검색 결과·주소 불러오기), `PlayerBar`(하단 재생바), `PlayerPage`(펼친 재생 화면),
|
||||||
|
`QueueList`(다음 트랙 + 섞기/모두 지우기/자동재생).
|
||||||
|
- `components/ui.tsx` — 로고, 썸네일, 서버 아이콘, 로딩 표시, 재생 중 이퀄라이저. `ToastProvider.tsx` — 하단 스낵바.
|
||||||
|
- `hooks/` — `usePlayer`(재생 상태·조작·단축키·자동재생), `useQueue`(대기열·섞기·지우기), `useServers`(서버 목록·최근 서버).
|
||||||
|
재생 상태·대기열은 `PlayerApp`에서 한 번만 구독해 화면 여러 곳에 내려준다.
|
||||||
|
- `lib/` — `auth.ts`(페이지 로그인 검사), `url.ts`(음악 주소 판별), `recent.ts`(최근 검색어), `api.ts`(봇 RPC).
|
||||||
|
|
||||||
### 5-2. 버튼·조작 동작 정리
|
### 5-3. 플레이어 화면 구성
|
||||||
|
|
||||||
| 위치 | 요소 | 동작 | 비활성 조건 |
|
| 화면 | 진입 | 보이는 것 |
|
||||||
|------|------|------|-------------|
|
|------|------|-----------|
|
||||||
| 상단 | ☰ | 왼쪽 메뉴 펼침/접힘(좁은 화면은 서랍) | — |
|
| 서버 홈 | 서버 선택 직후, 로고·"홈" | 왼쪽: 서버 아이콘·이름·권한·봇이 있는 음성채널, 노래 검색 버튼, (재생 전) 사용 안내, 최근 검색 칩 / 오른쪽: 재생 목록(자동재생 스위치, 지금 곡, 다음 트랙) |
|
||||||
| 상단 | 로고 | 서버를 고른 상태면 서버 페이지로, 아니면 홈으로 | — |
|
| 검색 결과 | 검색창 Enter, 최근 검색어 | 필터 칩(전체/노래/동영상/Spotify, 개수 표시), 상위 검색결과, 구역별 목록(5곡 + "모두 표시"), 실패 시 "다시 시도" |
|
||||||
| 상단 | 검색창 | Enter 로 검색 → `SEARCH_RESULT`. ✕ 로 검색어 지우기 | 로그아웃·서버 미선택 시 잠금(안내 문구가 상황별로 바뀜) |
|
| 주소 불러오기 | 유튜브·유튜브 뮤직·스포티파이 주소를 검색창에 붙여넣고 Enter | 곡/재생목록·앨범 구분, "대기열에 추가" → `POST /api/player/playlist` |
|
||||||
| 상단 | 프로필 사진 | 계정 메뉴(이름 + 로그아웃). 바깥 클릭/Esc 로 닫힘 | — |
|
| 재생 화면 | 재생바 곡 정보·⌃, 서버 홈의 목록 아이콘 | 넓은 화면: 큰 앨범아트 + 다음 트랙 / 좁은 화면: 전체 화면 플레이어(진행바·조작·볼륨) + 다음 트랙 |
|
||||||
| 왼쪽 메뉴 | 홈 / 서버 목록 / 내 서버 | 서버 페이지 / 서버 고르기 / 해당 서버로 바로 전환 | — |
|
|
||||||
| 서버 페이지 | 노래 검색 | 상단 검색창에 포커스(좁은 화면은 검색 모드로 전환) | — |
|
|
||||||
| 서버 페이지 | 목록 아이콘 | 재생 화면 열기 | — |
|
|
||||||
| 검색 | 필터 칩 | 해당 출처 결과만 전부 보기 | 결과 0건인 칩 |
|
|
||||||
| 검색 | 곡 행(행 전체가 버튼) / 상위 검색결과 "재생" | `POST /api/player/play` → 대기열 추가(재생 중이 아니면 바로 재생). 스낵바로 결과 알림. 썸네일 위 재생 아이콘은 마우스면 hover 때, 터치 화면이면 항상 | 서버 미선택·미로그인 |
|
|
||||||
| 재생바 | 진행바(위쪽 가장자리 빨간 선) | hover 시 굵어지고 손잡이 표시. 드래그/클릭/키보드로 탐색 → `POST /api/player/seek` | 봇 세션 없음/재생 중 아님 |
|
|
||||||
| 재생바 | 재생/일시정지 · 다음 곡 | `POST /api/player/pause`(낙관적 UI, 실패 시 롤백) · `POST /api/player/skip` | 〃 |
|
|
||||||
| 재생바 | 이전 곡 | 미지원(봇 API 에 없음). 이유를 툴팁으로 표시하고 비활성 | 항상 |
|
|
||||||
| 재생바 | 곡 정보 / ⌃ | 재생 화면 열기·닫기 | — |
|
|
||||||
| 재생바 | 스피커 아이콘 | 클릭 = 음소거/해제(해제 시 직전 볼륨 복원). hover 시 왼쪽에 볼륨 슬라이더가 펼쳐짐 | 봇 세션 없음/재생 중 아님 |
|
|
||||||
| 재생바·재생 화면 | 볼륨 슬라이더 | 드래그 중에는 화면만, 손을 뗄 때 `POST /api/player/volume` 1회 전송 | 〃 |
|
|
||||||
| 다음 트랙 | 곡 | 그 곡부터 재생. 앞선 곡들은 대기열에서 빠진다(첫 곡이면 다음 곡으로 넘기기만) | — |
|
|
||||||
| 다음 트랙 | 손잡이 드래그 | 순서 변경(마우스 전용, hover 시 표시) | — |
|
|
||||||
| 다음 트랙 | ▲ ▼ | 한 칸씩 순서 이동 → `POST /api/queue/set` (터치 화면 전용, 드래그 대체) | 맨 위/맨 아래 |
|
|
||||||
| 다음 트랙 | 휴지통 | 해당 곡 제거 → `POST /api/queue/remove` (`encoded` 동봉해 엉뚱한 곡 삭제 방지). 마우스면 hover 때, 터치 화면이면 항상 표시 | — |
|
|
||||||
| 재생 화면 | ⌄ / Esc | 재생 화면 닫기 | — |
|
|
||||||
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 | 입력창 포커스 중에는 가로채지 않음 |
|
|
||||||
|
|
||||||
미지원(봇 API 한계): 이전 곡, 반복 재생, 셔플, 좋아요/가사. 유튜브 뮤직에 있는 이 버튼들은 동작하지 않는 가짜 버튼이 되므로 넣지 않았다.
|
- 재생바는 재생 중인 곡이 있을 때만 보인다. 브라우저 탭 제목에 지금 곡(▶/⏸)이 표시된다.
|
||||||
|
- 왼쪽 메뉴: 넓은 화면은 ☰ 로 펼침/접힘, 좁은 화면은 ☰ 로 서랍.
|
||||||
|
|
||||||
|
### 5-4. 버튼·조작 동작 정리
|
||||||
|
|
||||||
|
| 위치 | 요소 | 동작 |
|
||||||
|
|------|------|------|
|
||||||
|
| 상단 | 검색창 | Enter 로 검색(주소면 불러오기 화면). 누르면 최근 검색어 8개(✕로 개별 삭제) |
|
||||||
|
| 상단 | 프로필 | 서버 변경 · 로그아웃 |
|
||||||
|
| 왼쪽 메뉴 | 홈 / 검색 / 서버 변경 | 서버 홈 / 검색창 포커스 / `/servers` |
|
||||||
|
| 검색 | 곡 행·상위 검색결과 "재생" | `POST /api/player/play`. 요청 중에는 스피너를 띄우고 다른 곡 추가를 막아 중복 추가를 방지 |
|
||||||
|
| 재생바 | ⏮ | 지금 곡을 처음부터 다시(유튜브 뮤직 동작. 봇에 이전 곡 기록이 없다) |
|
||||||
|
| 재생바 | ⏯ / ⏭ / 진행바 | 일시정지·재개 / 다음 곡 / 탐색 |
|
||||||
|
| 재생바 | 스피커 | 클릭 = 음소거/해제, hover 시 볼륨 슬라이더 |
|
||||||
|
| 재생바 | 섞기 | 다음 트랙 섞기(2곡 이상일 때) |
|
||||||
|
| 다음 트랙 | 섞기 · 모두 지우기 | 섞기 / 지금 곡은 두고 나머지 비우기(되돌릴 수 없어 3초 안에 한 번 더 눌러야 실행) |
|
||||||
|
| 다음 트랙 | 자동재생 스위치 | 대기열이 끝나면 비슷한 곡 이어 재생. 재생 중이 아니어도 바꿀 수 있다(디스코드 버튼과 같은 설정) |
|
||||||
|
| 다음 트랙 | 곡 / 손잡이 / ▲▼ / 휴지통 | 그 곡부터 재생 / 드래그로 순서 변경(마우스) / 한 칸 이동(터치) / 삭제 |
|
||||||
|
| 재생 화면 | ⌄ / Esc | 닫기 |
|
||||||
|
| 전역 | 키보드 | `Space` 재생/일시정지, `M` 음소거, `←`/`→` 5초 탐색, `Ctrl/⌘+→` 다음 곡 |
|
||||||
|
|
||||||
|
미지원(봇 기능 없음): 이전 곡으로 돌아가기, 반복 재생, 좋아요/가사. 동작하지 않는 버튼이 되므로 넣지 않았다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export const authOptions: NextAuthOptions = {
|
|||||||
session: {
|
session: {
|
||||||
strategy: "jwt",
|
strategy: "jwt",
|
||||||
},
|
},
|
||||||
|
// 로그인/로그인 오류는 NextAuth 기본 화면 대신 우리 로그인 페이지로 보낸다.
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
error: "/login",
|
||||||
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
||||||
async jwt({ token, account, profile }) {
|
async jwt({ token, account, profile }) {
|
||||||
|
|||||||
32
page/src/app/api/player/autoplay/route.ts
Normal file
32
page/src/app/api/player/autoplay/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||||
|
|
||||||
|
interface AutoplayBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 자동재생(대기열이 끝나면 비슷한 곡 이어 재생) 켜기/끄기 토글 */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const sessionResult = await requireSession();
|
||||||
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
|
const bodyResult = await readJsonBody<AutoplayBody>(request);
|
||||||
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
|
|
||||||
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
|
const { status, body } = await botRpc({
|
||||||
|
channel: "player:recommend",
|
||||||
|
payload: { action: "player_recommend", serverId: serverIdResult.value, userId },
|
||||||
|
timeoutMs: 5000,
|
||||||
|
});
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`player/autoplay API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ export async function POST(request: Request) {
|
|||||||
userId,
|
userId,
|
||||||
track,
|
track,
|
||||||
},
|
},
|
||||||
|
// 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다.
|
||||||
|
timeoutMs: 15000,
|
||||||
});
|
});
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export async function POST(request: Request) {
|
|||||||
userId,
|
userId,
|
||||||
playlistUrl: urlResult.value,
|
playlistUrl: urlResult.value,
|
||||||
},
|
},
|
||||||
|
// 음성채널 접속 + 곡(재생목록) 해석까지 기다려야 해서 기본 3초로는 부족하다.
|
||||||
|
timeoutMs: 15000,
|
||||||
});
|
});
|
||||||
return NextResponse.json(body, { status });
|
return NextResponse.json(body, { status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
32
page/src/app/api/queue/shuffle/route.ts
Normal file
32
page/src/app/api/queue/shuffle/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Logger } from "@/lib/Logger";
|
||||||
|
import { botRpc, errorResponse, readJsonBody, requireSession, requireString } from "@/lib/api";
|
||||||
|
|
||||||
|
interface ShuffleBody {
|
||||||
|
serverId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 대기열 섞기(현재 곡은 그대로, 다음 트랙만 섞는다) */
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const sessionResult = await requireSession();
|
||||||
|
if (!sessionResult.ok) return sessionResult.response;
|
||||||
|
const userId = sessionResult.session.user.id;
|
||||||
|
|
||||||
|
const bodyResult = await readJsonBody<ShuffleBody>(request);
|
||||||
|
if (!bodyResult.ok) return bodyResult.response;
|
||||||
|
|
||||||
|
const serverIdResult = requireString(bodyResult.data.serverId, "serverId");
|
||||||
|
if (!serverIdResult.ok) return serverIdResult.response;
|
||||||
|
|
||||||
|
const { status, body } = await botRpc({
|
||||||
|
channel: "queue:shuffle",
|
||||||
|
payload: { action: "queue_shuffle", serverId: serverIdResult.value, userId },
|
||||||
|
timeoutMs: 5000,
|
||||||
|
});
|
||||||
|
return NextResponse.json(body, { status });
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`queue/shuffle API error: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return errorResponse("서버 오류가 발생했습니다.", 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
page/src/app/login/page.tsx
Normal file
17
page/src/app/login/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import LoginView from "@/components/pages/LoginView";
|
||||||
|
import { getPageSession, safeCallbackUrl } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const metadata = { title: "로그인 | MusicBot" };
|
||||||
|
|
||||||
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ callbackUrl?: string; error?: string }>;
|
||||||
|
}) {
|
||||||
|
const { callbackUrl, error } = await searchParams;
|
||||||
|
const target = safeCallbackUrl(callbackUrl);
|
||||||
|
const session = await getPageSession();
|
||||||
|
if (session?.user?.id) redirect(target);
|
||||||
|
return <LoginView callbackUrl={target} error={error} />;
|
||||||
|
}
|
||||||
@@ -1,133 +1,8 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
import { getPageSession } from "@/lib/auth";
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
/** 첫 화면: 로그인 안 했으면 로그인 페이지, 했으면 서버 선택 페이지로 보낸다. */
|
||||||
import { useSession } from "next-auth/react";
|
export default async function Home() {
|
||||||
import TopNav from "@/components/layout/TopNav";
|
const session = await getPageSession();
|
||||||
import LeftSidebar from "@/components/layout/LeftSidebar";
|
redirect(session?.user?.id ? "/servers" : "/login");
|
||||||
import MainContent from "@/components/player/MainContent";
|
|
||||||
import PlayerBar from "@/components/player/PlayerBar";
|
|
||||||
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<ViewMode>("SERVER_LIST");
|
|
||||||
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
// 왼쪽 메뉴: 넓은 화면은 펼침/접힘, 좁은 화면은 서랍
|
|
||||||
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");
|
|
||||||
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("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleGuide = () => {
|
|
||||||
// 넓은 화면이면 펼침/접힘, 좁은 화면이면 서랍 열기
|
|
||||||
if (window.matchMedia("(min-width: 1024px)").matches) setGuideExpanded((v) => !v);
|
|
||||||
else setDrawerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePlayer = useCallback(() => setIsPlayerOpen(false), []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-dvh flex-col overflow-hidden bg-yt-bg font-sans text-white">
|
|
||||||
<TopNav
|
|
||||||
onSearch={handleSearch}
|
|
||||||
onHome={handleHome}
|
|
||||||
onToggleGuide={handleToggleGuide}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1">
|
|
||||||
<LeftSidebar
|
|
||||||
expanded={guideExpanded}
|
|
||||||
drawerOpen={drawerOpen}
|
|
||||||
onCloseDrawer={() => setDrawerOpen(false)}
|
|
||||||
isAuthed={isAuthed}
|
|
||||||
viewMode={viewMode}
|
|
||||||
servers={servers}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
onHome={handleHome}
|
|
||||||
onServers={handleBackToServers}
|
|
||||||
onSelectServer={handleSelectServer}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="relative flex min-w-0 flex-1">
|
|
||||||
<MainContent
|
|
||||||
viewMode={viewMode}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
servers={servers}
|
|
||||||
isFetchingServers={isFetching}
|
|
||||||
onSelectServer={handleSelectServer}
|
|
||||||
onOpenPlayer={() => setIsPlayerOpen(true)}
|
|
||||||
showQueue={!isPlayerOpen}
|
|
||||||
player={player}
|
|
||||||
queue={queue}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showPlayback && isPlayerOpen && (
|
|
||||||
<PlayerPage player={player} queue={queue} onClose={closePlayer} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showPlayerBar && (
|
|
||||||
<PlayerBar
|
|
||||||
player={player}
|
|
||||||
isPlayerOpen={isPlayerOpen}
|
|
||||||
onTogglePlayer={() => setIsPlayerOpen((v) => !v)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
16
page/src/app/servers/[serverId]/page.tsx
Normal file
16
page/src/app/servers/[serverId]/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import PlayerApp from "@/components/pages/PlayerApp";
|
||||||
|
import { requirePageSession } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default async function ServerPlayerPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ serverId: string }>;
|
||||||
|
searchParams: Promise<{ q?: string }>;
|
||||||
|
}) {
|
||||||
|
const { serverId } = await params;
|
||||||
|
const { q } = await searchParams;
|
||||||
|
await requirePageSession(`/servers/${serverId}`);
|
||||||
|
// key: 서버를 바꾸면 재생 상태·검색 결과를 깨끗하게 새로 시작한다.
|
||||||
|
return <PlayerApp key={serverId} serverId={serverId} initialQuery={typeof q === "string" ? q : ""} />;
|
||||||
|
}
|
||||||
9
page/src/app/servers/page.tsx
Normal file
9
page/src/app/servers/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import ServerPicker from "@/components/pages/ServerPicker";
|
||||||
|
import { requirePageSession } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const metadata = { title: "서버 선택 | MusicBot" };
|
||||||
|
|
||||||
|
export default async function ServersPage() {
|
||||||
|
await requirePageSession("/servers");
|
||||||
|
return <ServerPicker />;
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
|||||||
{toast && (
|
{toast && (
|
||||||
<div
|
<div
|
||||||
role={toast.type === "error" ? "alert" : "status"}
|
role={toast.type === "error" ? "alert" : "status"}
|
||||||
className="fixed bottom-24 left-4 right-4 z-[9999] flex items-center gap-3 rounded bg-yt-menu px-4 py-3 text-sm text-white shadow-2xl ring-1 ring-white/10 sm:left-6 sm:right-auto sm:max-w-md"
|
className="fixed bottom-24 left-4 right-4 z-[9999] flex items-center gap-3 rounded bg-yt-menu px-4 py-3 text-sm text-white shadow-2xl ring-1 ring-white/10 sm:left-1/2 sm:right-auto sm:w-max sm:max-w-md sm:-translate-x-1/2"
|
||||||
>
|
>
|
||||||
{toast.type === "error" && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-yt-red" aria-hidden />}
|
{toast.type === "error" && <span className="h-2 w-2 flex-shrink-0 rounded-full bg-yt-red" aria-hidden />}
|
||||||
{toast.message}
|
{toast.message}
|
||||||
|
|||||||
85
page/src/components/layout/AccountMenu.tsx
Normal file
85
page/src/components/layout/AccountMenu.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
import { LogOut, Server } from "lucide-react";
|
||||||
|
|
||||||
|
/** 프로필 이미지가 없을 수도 있어(이미지 URL 빈 값) 깨진 아이콘 대신 이니셜을 보여준다. */
|
||||||
|
export 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 AccountMenu({ showServerSwitch = true }: { showServerSwitch?: boolean }) {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 바깥을 누르거나 Esc 로 닫는다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (status === "loading" || !session?.user) {
|
||||||
|
return <span className="h-8 w-8 animate-pulse rounded-full bg-yt-chip" aria-label="로그인 상태 확인 중" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-label={`${session.user.name ?? "내"} 계정 메뉴`}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Avatar user={session.user} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-12 z-50 w-56 overflow-hidden rounded-lg bg-yt-menu py-2 shadow-2xl ring-1 ring-white/10">
|
||||||
|
<p className="truncate px-4 pb-2 pt-1 text-sm text-white/70">{session.user.name}</p>
|
||||||
|
{showServerSwitch && (
|
||||||
|
<Link
|
||||||
|
href="/servers"
|
||||||
|
className="flex h-10 items-center gap-4 px-4 text-sm text-white hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<Server size={20} className="text-white/70" aria-hidden />
|
||||||
|
서버 변경
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Home, Library, Menu } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { ArrowLeftRight, Home, Menu, Search, Volume2 } from "lucide-react";
|
||||||
import type { DiscordServer } from "@/types/music";
|
import type { DiscordServer } from "@/types/music";
|
||||||
import type { ViewMode } from "@/app/page";
|
import type { ViewMode } from "@/components/pages/PlayerApp";
|
||||||
import { Logo, ServerAvatar } from "@/components/ui";
|
import { Logo, ServerAvatar } from "@/components/ui";
|
||||||
import { permissionLabel } from "@/hooks/useServers";
|
import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav";
|
||||||
|
|
||||||
interface LeftSidebarProps {
|
interface LeftSidebarProps {
|
||||||
/** 넓은 화면에서 펼친 메뉴(240px)인지, 아이콘만 있는 좁은 메뉴(72px)인지 */
|
/** 넓은 화면에서 펼친 메뉴(240px)인지, 아이콘만 있는 좁은 메뉴(72px)인지 */
|
||||||
@@ -11,97 +12,95 @@ interface LeftSidebarProps {
|
|||||||
/** 좁은 화면에서 서랍처럼 열린 상태 */
|
/** 좁은 화면에서 서랍처럼 열린 상태 */
|
||||||
drawerOpen: boolean;
|
drawerOpen: boolean;
|
||||||
onCloseDrawer: () => void;
|
onCloseDrawer: () => void;
|
||||||
isAuthed: boolean;
|
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
servers: DiscordServer[];
|
server: DiscordServer | null;
|
||||||
selectedServer: DiscordServer | null;
|
/** 봇이 들어가 있는 음성채널 이름(재생 중일 때) */
|
||||||
|
voiceChannelName: string | null;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
onServers: () => void;
|
|
||||||
onSelectServer: (server: DiscordServer) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 유튜브 뮤직 왼쪽 메뉴(가이드).
|
* 유튜브 뮤직 왼쪽 메뉴(가이드): 홈 / 검색 / 서버 변경 + 아래에 지금 조작 중인 서버.
|
||||||
* - 위: 홈 / 서버 목록(유튜브 뮤직의 "보관함" 자리)
|
|
||||||
* - 아래: 재생목록 자리에 "봇이 있는 내 서버" 목록 → 바로 서버 전환
|
|
||||||
* 넓은 화면은 ☰ 로 펼침/접힘(아이콘만), 좁은 화면은 ☰ 로 서랍을 연다.
|
* 넓은 화면은 ☰ 로 펼침/접힘(아이콘만), 좁은 화면은 ☰ 로 서랍을 연다.
|
||||||
*/
|
*/
|
||||||
export default function LeftSidebar({
|
export default function LeftSidebar({
|
||||||
expanded,
|
expanded,
|
||||||
drawerOpen,
|
drawerOpen,
|
||||||
onCloseDrawer,
|
onCloseDrawer,
|
||||||
isAuthed,
|
|
||||||
viewMode,
|
viewMode,
|
||||||
servers,
|
server,
|
||||||
selectedServer,
|
voiceChannelName,
|
||||||
onHome,
|
onHome,
|
||||||
onServers,
|
|
||||||
onSelectServer,
|
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
const nav = [
|
const focusSearch = () => window.dispatchEvent(new Event(FOCUS_SEARCH_EVENT));
|
||||||
{ key: "home", label: "홈", icon: Home, active: viewMode === "SERVER_DETAIL", onClick: onHome },
|
|
||||||
{ key: "servers", label: "서버 목록", icon: Library, active: viewMode === "SERVER_LIST", onClick: onServers },
|
type Item = { key: string; label: string; icon: typeof Home; active?: boolean } & (
|
||||||
|
| { href: string; onClick?: never }
|
||||||
|
| { onClick: () => void; href?: never }
|
||||||
|
);
|
||||||
|
const nav: Item[] = [
|
||||||
|
{ key: "home", label: "홈", icon: Home, active: viewMode === "HOME", onClick: onHome },
|
||||||
|
{ key: "search", label: "검색", icon: Search, active: viewMode === "SEARCH", onClick: focusSearch },
|
||||||
|
{ key: "switch", label: "서버 변경", icon: ArrowLeftRight, href: "/servers" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const itemClass = (active?: boolean) =>
|
||||||
|
`flex h-12 w-full items-center gap-5 rounded-lg px-4 text-left text-base text-white transition-colors ${active ? "bg-white/10 font-medium" : "hover:bg-white/10"}`;
|
||||||
|
const miniClass = (active?: boolean) =>
|
||||||
|
`flex h-[74px] w-full flex-col items-center justify-center gap-1.5 rounded-lg text-[10px] text-white transition-colors ${active ? "bg-white/10" : "hover:bg-white/10"}`;
|
||||||
|
|
||||||
|
const renderItem = (item: Item, cls: string, afterClick?: () => void) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<Icon size={24} strokeWidth={item.active ? 2.2 : 1.6} aria-hidden />
|
||||||
|
{item.label}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return item.href ? (
|
||||||
|
<Link href={item.href} className={cls}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
afterClick?.();
|
||||||
|
item.onClick?.();
|
||||||
|
}}
|
||||||
|
aria-current={item.active ? "page" : undefined}
|
||||||
|
className={cls}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** 펼친 메뉴 본문(넓은 화면 펼침 + 좁은 화면 서랍에서 같이 쓴다) */
|
/** 펼친 메뉴 본문(넓은 화면 펼침 + 좁은 화면 서랍에서 같이 쓴다) */
|
||||||
const full = (afterClick?: () => void) => (
|
const full = (afterClick?: () => void) => (
|
||||||
<div className="flex h-full flex-col overflow-hidden">
|
<div className="flex h-full flex-col">
|
||||||
<ul className="flex flex-col gap-1 px-2 pt-2">
|
<ul className="flex flex-col gap-1 px-2 pt-2">
|
||||||
{nav.map(({ key, label, icon: Icon, active, onClick }) => (
|
{nav.map((item) => (
|
||||||
<li key={key}>
|
<li key={item.key}>{renderItem(item, itemClass(item.active), afterClick)}</li>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onClick();
|
|
||||||
afterClick?.();
|
|
||||||
}}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
className={`flex h-12 w-full items-center gap-5 rounded-lg px-4 text-left text-base text-white transition-colors
|
|
||||||
${active ? "bg-white/10 font-medium" : "hover:bg-white/10"}`}
|
|
||||||
>
|
|
||||||
<Icon size={24} strokeWidth={active ? 2.2 : 1.6} aria-hidden />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{isAuthed && (
|
{server && (
|
||||||
<>
|
<div className="mx-3 mb-4 mt-auto rounded-xl bg-white/[0.06] p-3">
|
||||||
<hr className="mx-4 my-4 border-yt-line" />
|
<div className="flex items-center gap-3">
|
||||||
<p className="px-6 pb-2 text-sm font-medium text-white">내 서버</p>
|
<ServerAvatar server={server} className="h-10 w-10" iconSize={16} size={64} />
|
||||||
<ul className="flex-1 overflow-y-auto px-2 pb-4">
|
<span className="min-w-0">
|
||||||
{servers.length === 0 ? (
|
<span className="block truncate text-sm font-medium text-white">{server.name}</span>
|
||||||
<li className="px-4 py-2 text-xs leading-relaxed text-white/50">
|
<span className="block truncate text-xs text-white/60">조작 중인 서버</span>
|
||||||
음악 봇이 있는 서버가 여기에 표시됩니다.
|
</span>
|
||||||
</li>
|
</div>
|
||||||
) : (
|
<p className="mt-3 flex items-center gap-1.5 text-xs text-white/60">
|
||||||
servers.map((server) => {
|
<Volume2 size={14} className="flex-shrink-0" aria-hidden />
|
||||||
const active = selectedServer?.id === server.id;
|
<span className="truncate">
|
||||||
return (
|
{voiceChannelName ? `${voiceChannelName}에서 재생 중` : "봇이 음성 채널에 없어요"}
|
||||||
<li key={server.id}>
|
</span>
|
||||||
<button
|
</p>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => {
|
|
||||||
onSelectServer(server);
|
|
||||||
afterClick?.();
|
|
||||||
}}
|
|
||||||
aria-current={active ? "true" : undefined}
|
|
||||||
className={`flex w-full items-center gap-3 rounded-lg px-4 py-2 text-left transition-colors
|
|
||||||
${active ? "bg-white/10" : "hover:bg-white/10"}`}
|
|
||||||
>
|
|
||||||
<ServerAvatar server={server} className="h-8 w-8" iconSize={14} size={64} />
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm text-white">{server.name}</span>
|
|
||||||
<span className="block truncate text-xs text-white/70">{permissionLabel(server)}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -117,19 +116,8 @@ export default function LeftSidebar({
|
|||||||
full()
|
full()
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-1 px-1 pt-1">
|
<ul className="flex flex-col gap-1 px-1 pt-1">
|
||||||
{nav.map(({ key, label, icon: Icon, active, onClick }) => (
|
{nav.map((item) => (
|
||||||
<li key={key}>
|
<li key={item.key}>{renderItem(item, miniClass(item.active))}</li>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
className={`flex h-[74px] w-full flex-col items-center justify-center gap-1.5 rounded-lg text-[10px] text-white transition-colors
|
|
||||||
${active ? "bg-white/10" : "hover:bg-white/10"}`}
|
|
||||||
>
|
|
||||||
<Icon size={24} strokeWidth={active ? 2.2 : 1.6} aria-hidden />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -138,13 +126,8 @@ export default function LeftSidebar({
|
|||||||
{/* 좁은 화면: 서랍 메뉴 */}
|
{/* 좁은 화면: 서랍 메뉴 */}
|
||||||
{drawerOpen && (
|
{drawerOpen && (
|
||||||
<div className="fixed inset-0 z-[60] lg:hidden">
|
<div className="fixed inset-0 z-[60] lg:hidden">
|
||||||
<button
|
<button type="button" aria-label="메뉴 닫기" onClick={onCloseDrawer} className="absolute inset-0 bg-black/60" />
|
||||||
type="button"
|
<aside className="absolute inset-y-0 left-0 flex w-64 flex-col bg-yt-raised shadow-2xl" aria-label="메뉴">
|
||||||
aria-label="메뉴 닫기"
|
|
||||||
onClick={onCloseDrawer}
|
|
||||||
className="absolute inset-0 bg-black/60"
|
|
||||||
/>
|
|
||||||
<aside className="absolute inset-y-0 left-0 flex w-60 flex-col bg-yt-raised shadow-2xl" aria-label="메뉴">
|
|
||||||
<div className="flex h-16 flex-shrink-0 items-center gap-1 px-2">
|
<div className="flex h-16 flex-shrink-0 items-center gap-1 px-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,62 +1,49 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ArrowLeft, LogOut, Menu, Search, X } from "lucide-react";
|
import { ArrowLeft, Clock, Link2, Menu, Search, X } from "lucide-react";
|
||||||
import { signIn, signOut, useSession } from "next-auth/react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { DiscordServer } from "@/types/music";
|
|
||||||
import { Logo } from "@/components/ui";
|
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";
|
export const FOCUS_SEARCH_EVENT = "musicbot:focus-search";
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
|
/** 지금 화면에 걸린 검색어(뒤로가기 등으로 바뀌면 입력칸도 맞춘다) */
|
||||||
|
query: string;
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
onHome: () => void;
|
onHome: () => void;
|
||||||
onToggleGuide: () => 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) {
|
export default function TopNav({ query, onSearch, onHome, onToggleGuide }: TopNavProps) {
|
||||||
const { data: session, status } = useSession();
|
const [term, setTerm] = useState(query);
|
||||||
const [term, setTerm] = useState("");
|
|
||||||
const [mobileSearch, setMobileSearch] = useState(false);
|
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 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 = () => {
|
useEffect(() => {
|
||||||
if (canSearch && term.trim()) {
|
const sync = () => setRecent(readRecent());
|
||||||
onSearch(term.trim());
|
sync();
|
||||||
inputRef.current?.blur();
|
window.addEventListener(RECENT_EVENT, sync);
|
||||||
setMobileSearch(false);
|
return () => window.removeEventListener(RECENT_EVENT, sync);
|
||||||
}
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
// 서버 페이지의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
|
// 서버 홈의 "노래 검색" 버튼 → 검색창 포커스(좁은 화면이면 검색 모드로 전환)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
setMobileSearch(true);
|
setMobileSearch(true);
|
||||||
@@ -66,32 +53,39 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer
|
|||||||
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus);
|
return () => window.removeEventListener(FOCUS_SEARCH_EVENT, focus);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 프로필 메뉴: 바깥을 누르거나 Esc 로 닫는다.
|
// 검색창 바깥을 누르면 최근 검색어 목록을 닫는다.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!focused) return;
|
||||||
const onDown = (e: MouseEvent) => {
|
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("mousedown", onDown);
|
||||||
document.addEventListener("keydown", onKey);
|
return () => document.removeEventListener("mousedown", onDown);
|
||||||
return () => {
|
}, [focused]);
|
||||||
document.removeEventListener("mousedown", onDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [menuOpen]);
|
|
||||||
|
|
||||||
const handleLogoClick = () => {
|
const run = (value: string) => {
|
||||||
setTerm("");
|
const q = value.trim();
|
||||||
onHome();
|
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 searchPlaceholder = !isAuthed
|
const next = recent.filter((x) => x !== r);
|
||||||
? "로그인하면 검색할 수 있어요"
|
setRecent(next);
|
||||||
: !selectedServer
|
writeRecent(next);
|
||||||
? "먼저 서버를 선택해주세요"
|
};
|
||||||
: "노래, 아티스트, 유튜브·스포티파이 주소 검색";
|
|
||||||
|
const showDropdown = focused && (recent.length > 0 || isMusicUrl(term));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="z-40 flex-shrink-0 border-b border-yt-line bg-yt-bg">
|
<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">
|
<div className="absolute inset-0 z-10 flex items-center gap-2 bg-yt-bg px-2 md:hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMobileSearch(false)}
|
onClick={() => {
|
||||||
|
setMobileSearch(false);
|
||||||
|
setFocused(false);
|
||||||
|
}}
|
||||||
aria-label="검색 닫기"
|
aria-label="검색 닫기"
|
||||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full text-white hover:bg-yt-chip"
|
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 />
|
<Menu size={22} aria-hidden />
|
||||||
</button>
|
</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 />
|
<Logo />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 검색창: 유튜브 뮤직과 같은 반투명 상자 + 왼쪽 돋보기 + 지우기 버튼 */}
|
{/* 검색창 */}
|
||||||
<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"}`}
|
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
|
<form
|
||||||
role="search"
|
role="search"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submitSearch();
|
run(term);
|
||||||
}}
|
}}
|
||||||
className="group relative w-full max-w-[480px]"
|
className="relative w-full max-w-[480px]"
|
||||||
>
|
>
|
||||||
<Search
|
<Search className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/70" size={20} aria-hidden />
|
||||||
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white/70"
|
|
||||||
size={20}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
value={term}
|
value={term}
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
onChange={(e) => setTerm(e.target.value)}
|
||||||
disabled={!canSearch}
|
onFocus={() => setFocused(true)}
|
||||||
placeholder={searchPlaceholder}
|
onKeyDown={(e) => e.key === "Escape" && setFocused(false)}
|
||||||
|
placeholder="노래·아티스트 검색 또는 유튜브·스포티파이 주소 붙여넣기"
|
||||||
aria-label="노래 검색"
|
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 && (
|
{term && (
|
||||||
<button
|
<button
|
||||||
@@ -165,6 +162,42 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer
|
|||||||
<X size={18} aria-hidden />
|
<X size={18} aria-hidden />
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,62 +205,18 @@ export default function TopNav({ onSearch, onHome, onToggleGuide, selectedServer
|
|||||||
|
|
||||||
{/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
|
{/* 오른쪽: (좁은 화면) 검색 버튼 + 프로필 */}
|
||||||
<div className="flex flex-shrink-0 items-center gap-1">
|
<div className="flex flex-shrink-0 items-center gap-1">
|
||||||
{canSearch && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => {
|
||||||
onClick={() => {
|
setMobileSearch(true);
|
||||||
setMobileSearch(true);
|
requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
}}
|
||||||
}}
|
aria-label="검색"
|
||||||
aria-label="검색"
|
className="flex h-10 w-10 items-center justify-center rounded-full text-white hover:bg-yt-chip md:hidden"
|
||||||
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 />
|
||||||
<Search size={22} aria-hidden />
|
</button>
|
||||||
</button>
|
<AccountMenu />
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
89
page/src/components/pages/LoginView.tsx
Normal file
89
page/src/components/pages/LoginView.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { Headphones, ListMusic, Server } from "lucide-react";
|
||||||
|
import { Logo, Spinner } from "@/components/ui";
|
||||||
|
|
||||||
|
/** 디스코드 로고(공식 색상 버튼 안에 넣는 흰색 마크) */
|
||||||
|
function DiscordMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden fill="currentColor">
|
||||||
|
<path d="M20.32 4.37A19.8 19.8 0 0 0 15.4 2.85a13.8 13.8 0 0 0-.63 1.29 18.4 18.4 0 0 0-5.53 0 13.6 13.6 0 0 0-.64-1.29 19.7 19.7 0 0 0-4.92 1.52C.53 9.05-.32 13.6.1 18.1a19.9 19.9 0 0 0 6.03 3.05c.49-.66.92-1.36 1.29-2.1a12.9 12.9 0 0 1-2.03-.98c.17-.12.34-.25.5-.38a14.2 14.2 0 0 0 12.22 0l.5.38c-.65.38-1.33.71-2.04.98.37.74.8 1.44 1.29 2.1a19.8 19.8 0 0 0 6.03-3.05c.5-5.2-.84-9.71-3.57-13.73ZM8.02 15.33c-1.18 0-2.16-1.09-2.16-2.42s.95-2.42 2.16-2.42c1.2 0 2.18 1.09 2.16 2.42 0 1.33-.96 2.42-2.16 2.42Zm7.97 0c-1.19 0-2.16-1.09-2.16-2.42s.95-2.42 2.16-2.42c1.2 0 2.18 1.09 2.16 2.42 0 1.33-.95 2.42-2.16 2.42Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERROR_TEXT: Record<string, string> = {
|
||||||
|
OAuthCallback: "디스코드 로그인 중 문제가 생겼습니다. 다시 시도해 주세요.",
|
||||||
|
AccessDenied: "디스코드에서 로그인 권한을 허용하지 않았습니다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
{ icon: ListMusic, title: "디스코드로 로그인", desc: "계정 비밀번호는 이 사이트에 저장되지 않아요." },
|
||||||
|
{ icon: Server, title: "서버 선택", desc: "음악 봇이 들어가 있는 내 서버만 보여요." },
|
||||||
|
{ icon: Headphones, title: "음성 채널에서 감상", desc: "음성 채널에 들어간 뒤 노래를 검색해 누르면 봇이 재생해요." },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 로그인 전용 페이지. 로그인해야만 서버 선택·플레이어로 들어갈 수 있다. */
|
||||||
|
export default function LoginView({ callbackUrl, error }: { callbackUrl: string; error?: string }) {
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const errorText = error ? (ERROR_TEXT[error] ?? "로그인하지 못했습니다. 다시 시도해 주세요.") : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-yt-bg px-5 py-10 text-white">
|
||||||
|
{/* 은은한 빨간 빛 배경 */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute -top-40 left-1/2 h-[520px] w-[820px] -translate-x-1/2 rounded-full bg-yt-red/20 blur-[120px]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Logo className="scale-[1.35]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 rounded-2xl bg-yt-raised/80 p-6 shadow-2xl ring-1 ring-white/10 backdrop-blur sm:p-8">
|
||||||
|
<h1 className="text-center text-2xl font-bold">로그인</h1>
|
||||||
|
<p className="mt-2 text-center text-sm leading-relaxed text-white/70">
|
||||||
|
디스코드 계정으로 로그인하면 브라우저에서 음악 봇을 조작할 수 있어요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{errorText && (
|
||||||
|
<p role="alert" className="mt-5 rounded-lg bg-yt-red/15 px-4 py-3 text-sm text-red-200 ring-1 ring-yt-red/40">
|
||||||
|
{errorText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
setPending(true);
|
||||||
|
signIn("discord", { callbackUrl });
|
||||||
|
}}
|
||||||
|
className="mt-6 flex h-12 w-full items-center justify-center gap-3 rounded-full bg-[#5865F2] text-base font-medium text-white transition-colors hover:bg-[#4752C4] disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{pending ? <Spinner size={20} label="디스코드로 이동 중" /> : <DiscordMark />}
|
||||||
|
{pending ? "디스코드로 이동 중..." : "Discord로 로그인"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ol className="mt-8 flex flex-col gap-4 border-t border-yt-line pt-6">
|
||||||
|
{STEPS.map(({ icon: Icon, title, desc }, i) => (
|
||||||
|
<li key={title} className="flex gap-4">
|
||||||
|
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-white/10 text-white">
|
||||||
|
<Icon size={18} aria-hidden />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium">
|
||||||
|
{i + 1}. {title}
|
||||||
|
</span>
|
||||||
|
<span className="block text-sm text-white/60">{desc}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
page/src/components/pages/PlayerApp.tsx
Normal file
164
page/src/components/pages/PlayerApp.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import TopNav from "@/components/layout/TopNav";
|
||||||
|
import LeftSidebar from "@/components/layout/LeftSidebar";
|
||||||
|
import MainContent from "@/components/player/MainContent";
|
||||||
|
import PlayerBar from "@/components/player/PlayerBar";
|
||||||
|
import PlayerPage from "@/components/player/PlayerPage";
|
||||||
|
import { Spinner } from "@/components/ui";
|
||||||
|
import { cleanArtist, usePlayer } from "@/hooks/usePlayer";
|
||||||
|
import { useQueue } from "@/hooks/useQueue";
|
||||||
|
import { setLastServerId, useServers } from "@/hooks/useServers";
|
||||||
|
import type { DiscordServer } from "@/types/music";
|
||||||
|
|
||||||
|
/** 플레이어 안의 화면: 서버 홈 / 검색 결과 */
|
||||||
|
export type ViewMode = "HOME" | "SEARCH";
|
||||||
|
|
||||||
|
/** 주소창의 ?q= 를 읽는다(뒤로가기로 검색 화면을 오갈 수 있게 검색어를 주소에 남긴다) */
|
||||||
|
function readQuery() {
|
||||||
|
return new URLSearchParams(window.location.search).get("q") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 유튜브 뮤직 구조의 봇 플레이어(서버를 고른 뒤 들어오는 화면):
|
||||||
|
* 상단 바(☰ 로고 · 검색 · 프로필)
|
||||||
|
* [왼쪽 메뉴] [본문: 서버 홈 / 검색 결과] ← 재생 화면(⌃)은 본문 위를 덮는다
|
||||||
|
* 하단 재생바
|
||||||
|
*/
|
||||||
|
export default function PlayerApp({ serverId, initialQuery }: { serverId: string; initialQuery: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { status } = useSession();
|
||||||
|
const [searchQuery, setSearchQuery] = useState(initialQuery);
|
||||||
|
const viewMode: ViewMode = searchQuery ? "SEARCH" : "HOME";
|
||||||
|
// 왼쪽 메뉴: 넓은 화면은 펼침/접힘, 좁은 화면은 서랍
|
||||||
|
const [guideExpanded, setGuideExpanded] = useState(true);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [isPlayerOpen, setIsPlayerOpen] = useState(false);
|
||||||
|
|
||||||
|
const { servers, loaded, isFetching, error } = useServers();
|
||||||
|
const found = servers.find((s) => s.id === serverId) ?? null;
|
||||||
|
// 목록을 받기 전에도 재생 상태 구독은 바로 시작한다(봇이 서버 멤버 여부를 다시 확인한다).
|
||||||
|
const server: DiscordServer = useMemo(
|
||||||
|
() => found ?? { id: serverId, name: "", icon: null, owner: false, permissions: "0" },
|
||||||
|
[found, serverId],
|
||||||
|
);
|
||||||
|
// 재생 상태·대기열은 재생바, 재생 화면, 서버 홈이 같이 쓰므로 여기서 한 번만 구독한다.
|
||||||
|
const player = usePlayer(server);
|
||||||
|
const queue = useQueue(server);
|
||||||
|
|
||||||
|
// 세션이 만료되면 로그인 페이지로
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated") router.replace(`/login?callbackUrl=${encodeURIComponent(`/servers/${serverId}`)}`);
|
||||||
|
}, [status, router, serverId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (found) setLastServerId(found.id);
|
||||||
|
}, [found]);
|
||||||
|
|
||||||
|
// 뒤로/앞으로 가기로 검색 화면을 오간다.
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => setSearchQuery(readQuery());
|
||||||
|
window.addEventListener("popstate", onPop);
|
||||||
|
return () => window.removeEventListener("popstate", onPop);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 탭 제목에 지금 곡을 보여준다(다른 탭에 있어도 무슨 곡인지 알 수 있게).
|
||||||
|
useEffect(() => {
|
||||||
|
const t = player.track?.info;
|
||||||
|
const base = found?.name ? `${found.name} | MusicBot` : "MusicBot";
|
||||||
|
document.title = t?.title ? `${player.isPaused ? "⏸" : "▶"} ${t.title} · ${cleanArtist(t.author)} | MusicBot` : base;
|
||||||
|
}, [player.track, player.isPaused, found]);
|
||||||
|
|
||||||
|
const goHome = () => {
|
||||||
|
setIsPlayerOpen(false);
|
||||||
|
if (searchQuery) {
|
||||||
|
setSearchQuery("");
|
||||||
|
window.history.pushState(null, "", `/servers/${serverId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (query: string) => {
|
||||||
|
setIsPlayerOpen(false);
|
||||||
|
setSearchQuery(query);
|
||||||
|
window.history.pushState(null, "", `/servers/${serverId}?q=${encodeURIComponent(query)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleGuide = () => {
|
||||||
|
// 넓은 화면이면 펼침/접힘, 좁은 화면이면 서랍 열기
|
||||||
|
if (window.matchMedia("(min-width: 1024px)").matches) setGuideExpanded((v) => !v);
|
||||||
|
else setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePlayer = useCallback(() => setIsPlayerOpen(false), []);
|
||||||
|
|
||||||
|
// 목록을 받았는데 이 서버가 없으면(봇이 없거나 내가 멤버가 아님) 서버 선택으로 안내
|
||||||
|
if (loaded && !isFetching && !found) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh flex-col items-center justify-center gap-4 bg-yt-bg px-6 text-center text-white">
|
||||||
|
<p className="text-xl font-bold">이 서버를 열 수 없어요</p>
|
||||||
|
<p className="max-w-sm text-sm leading-relaxed text-white/60">
|
||||||
|
{error ?? "음악 봇이 없는 서버이거나, 내가 들어가 있지 않은 서버예요."}
|
||||||
|
</p>
|
||||||
|
<Link href="/servers" className="mt-2 h-10 rounded-full bg-white px-5 text-sm font-medium leading-10 text-black hover:bg-white/85">
|
||||||
|
서버 선택으로
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== "authenticated") {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-dvh items-center justify-center bg-yt-bg">
|
||||||
|
<Spinner />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh flex-col overflow-hidden bg-yt-bg font-sans text-white">
|
||||||
|
<TopNav query={searchQuery} onSearch={handleSearch} onHome={goHome} onToggleGuide={handleToggleGuide} />
|
||||||
|
|
||||||
|
<div className="relative flex min-h-0 flex-1">
|
||||||
|
<LeftSidebar
|
||||||
|
expanded={guideExpanded}
|
||||||
|
drawerOpen={drawerOpen}
|
||||||
|
onCloseDrawer={() => setDrawerOpen(false)}
|
||||||
|
viewMode={viewMode}
|
||||||
|
server={found}
|
||||||
|
voiceChannelName={player.voiceChannelName}
|
||||||
|
onHome={goHome}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex min-w-0 flex-1">
|
||||||
|
<MainContent
|
||||||
|
viewMode={viewMode}
|
||||||
|
server={server}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onOpenPlayer={() => setIsPlayerOpen(true)}
|
||||||
|
showQueue={!isPlayerOpen}
|
||||||
|
player={player}
|
||||||
|
queue={queue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isPlayerOpen && <PlayerPage player={player} queue={queue} onClose={closePlayer} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 유튜브 뮤직처럼 재생 중인 곡이 있을 때만 재생바를 띄운다 */}
|
||||||
|
{player.track && (
|
||||||
|
<PlayerBar
|
||||||
|
player={player}
|
||||||
|
onShuffle={queue.shuffle}
|
||||||
|
canShuffle={queue.queue.length >= 2}
|
||||||
|
isPlayerOpen={isPlayerOpen}
|
||||||
|
onTogglePlayer={() => setIsPlayerOpen((v) => !v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
page/src/components/pages/ServerPicker.tsx
Normal file
133
page/src/components/pages/ServerPicker.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { RefreshCw, Search } from "lucide-react";
|
||||||
|
import AccountMenu from "@/components/layout/AccountMenu";
|
||||||
|
import { Logo, ServerAvatar, Spinner } from "@/components/ui";
|
||||||
|
import { getLastServerId, permissionLabel, setLastServerId, useServers } from "@/hooks/useServers";
|
||||||
|
|
||||||
|
/** 서버가 이만큼 넘으면 이름 검색 칸을 보여준다 */
|
||||||
|
const FILTER_THRESHOLD = 8;
|
||||||
|
|
||||||
|
/** 서버 선택 전용 페이지. 여기서 서버를 골라야 플레이어로 들어간다. */
|
||||||
|
export default function ServerPicker() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const { servers, isFetching, error, loaded, refresh } = useServers();
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [lastId, setLastId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 세션이 만료됐으면 로그인 페이지로
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "unauthenticated") router.replace("/login?callbackUrl=/servers");
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
// localStorage 는 브라우저에서만 읽을 수 있다(서버 렌더와 어긋나지 않게 마운트 뒤에 읽음).
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setLastId(getLastServerId());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 최근 사용한 서버를 맨 앞으로
|
||||||
|
const ordered = useMemo(() => {
|
||||||
|
const list = [...servers];
|
||||||
|
const i = list.findIndex((s) => s.id === lastId);
|
||||||
|
if (i > 0) list.unshift(list.splice(i, 1)[0]);
|
||||||
|
const q = filter.trim().toLowerCase();
|
||||||
|
return q ? list.filter((s) => s.name.toLowerCase().includes(q)) : list;
|
||||||
|
}, [servers, lastId, filter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-yt-bg text-white">
|
||||||
|
<header className="sticky top-0 z-40 border-b border-yt-line bg-yt-bg/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex h-16 max-w-[1200px] items-center justify-between px-4 sm:px-8">
|
||||||
|
<Logo />
|
||||||
|
<AccountMenu showServerSwitch={false} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-[1200px] px-4 pb-16 pt-8 sm:px-8">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-white/70">{session?.user?.name ? `${session.user.name}님, 안녕하세요` : " "}</p>
|
||||||
|
<h1 className="mt-1 text-[28px] font-bold leading-tight sm:text-[32px]">어느 서버에서 들을까요?</h1>
|
||||||
|
<p className="mt-2 text-sm text-white/60">음악 봇이 들어가 있는 내 서버만 보여요.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="flex h-9 items-center gap-2 rounded-full border border-white/20 px-4 text-sm font-medium text-white transition-colors hover:bg-white/10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isFetching ? "animate-spin" : ""} aria-hidden />
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{servers.length > FILTER_THRESHOLD && (
|
||||||
|
<div className="relative mt-6 max-w-sm">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-white/60" size={18} aria-hidden />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="서버 이름으로 찾기"
|
||||||
|
aria-label="서버 이름으로 찾기"
|
||||||
|
className="h-10 w-full rounded-lg border border-white/20 bg-white/10 pl-10 pr-3 text-sm text-white placeholder:text-white/50 focus:border-white/30 focus:bg-yt-raised focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="mt-6 rounded-lg bg-yt-red/15 px-4 py-3 text-sm text-red-200 ring-1 ring-yt-red/40">
|
||||||
|
{error} 잠시 뒤 새로고침을 눌러 주세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
{!loaded ? (
|
||||||
|
<div className="flex justify-center py-24">
|
||||||
|
<Spinner label="서버 목록을 불러오는 중" />
|
||||||
|
</div>
|
||||||
|
) : servers.length === 0 ? (
|
||||||
|
<div className="mx-auto flex max-w-md flex-col items-center gap-3 py-20 text-center">
|
||||||
|
<p className="text-lg font-medium">표시할 서버가 없어요</p>
|
||||||
|
<p className="text-sm leading-relaxed text-white/60">
|
||||||
|
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있어요.
|
||||||
|
봇을 서버에 초대한 뒤 새로고침을 눌러 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : ordered.length === 0 ? (
|
||||||
|
<p className="py-16 text-center text-sm text-white/60">“{filter}”과(와) 일치하는 서버가 없어요.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid grid-cols-3 gap-x-3 gap-y-6 sm:gap-x-4 sm:gap-y-8 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||||
|
{ordered.map((server) => (
|
||||||
|
<li key={server.id}>
|
||||||
|
<Link
|
||||||
|
href={`/servers/${server.id}`}
|
||||||
|
onClick={() => setLastServerId(server.id)}
|
||||||
|
className="group block text-center focus:outline-none"
|
||||||
|
>
|
||||||
|
<span className="relative mb-3 block">
|
||||||
|
<ServerAvatar server={server} className="aspect-square w-full" iconSize={40} size={256} />
|
||||||
|
<span className="absolute inset-0 rounded-full transition-colors group-hover:bg-black/30 group-focus-visible:ring-2 group-focus-visible:ring-white" />
|
||||||
|
{server.id === lastId && (
|
||||||
|
<span className="absolute left-1/2 top-2 -translate-x-1/2 rounded-full bg-white px-2 py-0.5 text-[11px] font-medium text-black shadow">
|
||||||
|
최근
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-base font-medium">{server.name}</span>
|
||||||
|
<span className="block truncate text-sm text-white/60">{permissionLabel(server)}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { signIn, useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { ListMusic, ListPlus, Play, Search, SearchX } from "lucide-react";
|
import { Clock, Headphones, Link2, ListMusic, ListPlus, Play, Search, SearchX } from "lucide-react";
|
||||||
import type { ViewMode } from "@/app/page";
|
import type { ViewMode } from "@/components/pages/PlayerApp";
|
||||||
import { useToast } from "@/components/ToastProvider";
|
import { useToast } from "@/components/ToastProvider";
|
||||||
import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav";
|
import { FOCUS_SEARCH_EVENT } from "@/components/layout/TopNav";
|
||||||
import QueueList from "@/components/player/QueueList";
|
import QueueList from "@/components/player/QueueList";
|
||||||
import { Logo, ServerAvatar, Spinner, Thumb } from "@/components/ui";
|
import { ServerAvatar, Spinner, Thumb } from "@/components/ui";
|
||||||
import { formatTime, type PlayerState } from "@/hooks/usePlayer";
|
import { formatTime, type PlayerState } from "@/hooks/usePlayer";
|
||||||
import type { QueueState } from "@/hooks/useQueue";
|
import type { QueueState } from "@/hooks/useQueue";
|
||||||
import { permissionLabel } from "@/hooks/useServers";
|
import { permissionLabel } from "@/hooks/useServers";
|
||||||
|
import { RECENT_EVENT, readRecent } from "@/lib/recent";
|
||||||
|
import { isCollectionUrl, isMusicUrl } from "@/lib/url";
|
||||||
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
|
||||||
|
|
||||||
type SourceKey = keyof SearchResults;
|
type SourceKey = keyof SearchResults;
|
||||||
@@ -25,15 +27,15 @@ const SOURCES: { key: SourceKey; title: string; kind: string; wide: boolean }[]
|
|||||||
/** "전체" 탭에서 구역마다 먼저 보여줄 곡 수(나머지는 "모두 표시") */
|
/** "전체" 탭에서 구역마다 먼저 보여줄 곡 수(나머지는 "모두 표시") */
|
||||||
const PREVIEW_COUNT = 5;
|
const PREVIEW_COUNT = 5;
|
||||||
|
|
||||||
|
const EMPTY_RESULTS: SearchResults = { spotify: [], youtubeMusic: [], youtubeVideo: [] };
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
viewMode: ViewMode;
|
viewMode: ViewMode;
|
||||||
selectedServer: DiscordServer | null;
|
server: DiscordServer;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
servers: DiscordServer[];
|
onSearch: (query: string) => void;
|
||||||
isFetchingServers: boolean;
|
|
||||||
onSelectServer: (server: DiscordServer) => void;
|
|
||||||
onOpenPlayer: () => void;
|
onOpenPlayer: () => void;
|
||||||
/** 재생 화면이 열려 있으면 서버 페이지의 대기열을 그리지 않는다(같은 목록이 두 번 보이지 않게) */
|
/** 재생 화면이 열려 있으면 서버 홈의 대기열을 그리지 않는다(같은 목록이 두 번 보이지 않게) */
|
||||||
showQueue: boolean;
|
showQueue: boolean;
|
||||||
player: PlayerState;
|
player: PlayerState;
|
||||||
queue: QueueState;
|
queue: QueueState;
|
||||||
@@ -41,145 +43,128 @@ interface MainContentProps {
|
|||||||
|
|
||||||
export default function MainContent({
|
export default function MainContent({
|
||||||
viewMode,
|
viewMode,
|
||||||
selectedServer,
|
server,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
servers,
|
onSearch,
|
||||||
isFetchingServers,
|
|
||||||
onSelectServer,
|
|
||||||
onOpenPlayer,
|
onOpenPlayer,
|
||||||
showQueue,
|
showQueue,
|
||||||
player,
|
player,
|
||||||
queue,
|
queue,
|
||||||
}: MainContentProps) {
|
}: MainContentProps) {
|
||||||
const { data: session, status } = useSession();
|
const { data: session } = useSession();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
const [searchResults, setSearchResults] = useState<SearchResults>({
|
const [searchResults, setSearchResults] = useState<SearchResults>(EMPTY_RESULTS);
|
||||||
spotify: [],
|
|
||||||
youtubeMusic: [],
|
|
||||||
youtubeVideo: []
|
|
||||||
});
|
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState<Filter>("all");
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
// 같은 곡을 연달아 눌러 중복으로 쌓이지 않게, 요청 중인 항목을 표시한다.
|
||||||
|
const [pendingKey, setPendingKey] = useState<string | null>(null);
|
||||||
|
const [recent, setRecent] = useState<string[]>([]);
|
||||||
|
const [retry, setRetry] = useState(0);
|
||||||
|
|
||||||
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
|
const isUrlQuery = isMusicUrl(searchQuery);
|
||||||
if (!selectedServer) {
|
|
||||||
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = session?.user?.id;
|
useEffect(() => {
|
||||||
if (!userId) {
|
const sync = () => setRecent(readRecent());
|
||||||
|
sync();
|
||||||
|
window.addEventListener(RECENT_EVENT, sync);
|
||||||
|
return () => window.removeEventListener(RECENT_EVENT, sync);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 곡 하나(player_play) 또는 주소(player_playlist: 곡·재생목록·앨범 모두)를 대기열에 넣는다. */
|
||||||
|
const addToQueue = async (key: string, body: Record<string, unknown>, endpoint: string, okText: string) => {
|
||||||
|
if (!session?.user?.id) {
|
||||||
showToast("로그인이 필요합니다.", "error");
|
showToast("로그인이 필요합니다.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (pendingKey) return;
|
||||||
let endpoint = "";
|
setPendingKey(key);
|
||||||
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
|
|
||||||
if (actionType === 'player_play') {
|
|
||||||
endpoint = "/api/player/play";
|
|
||||||
bodyData.track = track;
|
|
||||||
} else if (actionType === 'player_playlist') {
|
|
||||||
endpoint = "/api/player/playlist";
|
|
||||||
bodyData.playlistUrl = playlistUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(bodyData),
|
body: JSON.stringify({ serverId: server.id, ...body }),
|
||||||
});
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
showToast("재생목록에 곡을 추가했습니다!", "success");
|
showToast(okText, "success");
|
||||||
} else {
|
} else {
|
||||||
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Music Action Error:", error);
|
console.error("Music Action Error:", error);
|
||||||
showToast('서버와 통신할 수 없습니다.', "error");
|
showToast('서버와 통신할 수 없습니다.', "error");
|
||||||
|
} finally {
|
||||||
|
setPendingKey(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addTrack = (track: SearchTrack, key: string) =>
|
||||||
|
addToQueue(key, { track }, "/api/player/play", `"${track.title}"을(를) 대기열에 추가했어요.`);
|
||||||
|
|
||||||
|
const addUrl = (url: string) =>
|
||||||
|
addToQueue(
|
||||||
|
"url",
|
||||||
|
{ playlistUrl: url },
|
||||||
|
"/api/player/playlist",
|
||||||
|
isCollectionUrl(url) ? "재생목록을 대기열에 추가했어요. 나머지 곡은 이어서 채워져요." : "대기열에 추가했어요.",
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewMode === "SEARCH_RESULT" && searchQuery) {
|
if (viewMode !== "SEARCH" || !searchQuery || isMusicUrl(searchQuery)) return;
|
||||||
// 새 검색마다 결과·탭을 초기화하는 의도적 패턴
|
let cancelled = false;
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
// 새 검색마다 결과·탭을 초기화하는 의도적 패턴
|
||||||
setIsSearching(true);
|
|
||||||
setFilter("all");
|
setIsSearching(true);
|
||||||
setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] });
|
setSearchError(null);
|
||||||
|
setFilter("all");
|
||||||
|
setSearchResults(EMPTY_RESULTS);
|
||||||
|
|
||||||
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const data = await res.json();
|
const data = await res.json().catch(() => null);
|
||||||
if (res.ok && data) {
|
if (cancelled) return;
|
||||||
setSearchResults({
|
if (res.ok && data) {
|
||||||
spotify: data.spotify || [],
|
setSearchResults({
|
||||||
youtubeMusic: data.youtubeMusic || [],
|
spotify: data.spotify || [],
|
||||||
youtubeVideo: data.youtubeVideo || []
|
youtubeMusic: data.youtubeMusic || [],
|
||||||
});
|
youtubeVideo: data.youtubeVideo || []
|
||||||
} else {
|
});
|
||||||
console.error("Search Error:", data);
|
} else {
|
||||||
}
|
setSearchError((data && (data.error || data.message)) || "검색하지 못했습니다.");
|
||||||
})
|
}
|
||||||
.catch(err => console.error("Fetch Error:", err))
|
})
|
||||||
.finally(() => setIsSearching(false));
|
.catch(() => !cancelled && setSearchError("서버와 통신할 수 없습니다."))
|
||||||
}
|
.finally(() => !cancelled && setIsSearching(false));
|
||||||
}, [viewMode, searchQuery]);
|
// 결과가 늦게 와도 이미 다른 검색어로 넘어갔으면 버린다.
|
||||||
|
return () => {
|
||||||
if (status === "unauthenticated") {
|
cancelled = true;
|
||||||
return (
|
};
|
||||||
<main className="flex flex-1 flex-col items-center justify-center gap-6 bg-yt-bg px-6 text-center">
|
}, [viewMode, searchQuery, retry]);
|
||||||
<Logo className="scale-150" />
|
|
||||||
<div className="mt-4">
|
|
||||||
<h2 className="text-2xl font-bold sm:text-3xl">디스코드로 로그인하고 음악을 들어 보세요</h2>
|
|
||||||
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-white/70">
|
|
||||||
로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고,
|
|
||||||
브라우저에서 바로 노래를 검색·재생할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => signIn("discord")}
|
|
||||||
className="h-10 rounded-full bg-white px-6 text-sm font-medium text-black transition-colors hover:bg-white/85"
|
|
||||||
>
|
|
||||||
Discord로 로그인
|
|
||||||
</button>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status === "loading") {
|
|
||||||
return (
|
|
||||||
<main className="flex flex-1 items-center justify-center bg-yt-bg">
|
|
||||||
<Spinner />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 곡 한 줄(유튜브 뮤직 검색 결과 행): 썸네일 · 제목 · "종류 • 아티스트 • 길이".
|
* 곡 한 줄(유튜브 뮤직 검색 결과 행): 썸네일 · 제목 · "종류 • 아티스트 • 길이".
|
||||||
* 행 전체가 버튼이라 어디를 눌러도 대기열에 추가된다.
|
* 행 전체가 버튼이라 어디를 눌러도 대기열에 추가된다.
|
||||||
*/
|
*/
|
||||||
const renderTrackRow = (track: SearchTrack, kind: string, wide: boolean) => {
|
const renderTrackRow = (track: SearchTrack, kind: string, wide: boolean, sourceKey: string, index: number) => {
|
||||||
|
const key = `${sourceKey}:${track.videoId || track.id || track.url || index}`;
|
||||||
|
const pending = pendingKey === key;
|
||||||
const meta = [kind, track.artist, track.duration ? formatTime(track.duration) : null].filter(Boolean).join(" • ");
|
const meta = [kind, track.artist, track.duration ? formatTime(track.duration) : null].filter(Boolean).join(" • ");
|
||||||
return (
|
return (
|
||||||
<li key={track.videoId || track.id || track.url || track.title} className="border-b border-yt-line last:border-b-0">
|
<li key={key} className="border-b border-yt-line last:border-b-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleMusicAction('player_play', track)}
|
onClick={() => addTrack(track, key)}
|
||||||
aria-label={`${track.title} 재생목록에 추가`}
|
disabled={!!pendingKey}
|
||||||
className="group flex w-full items-center gap-4 py-2 text-left focus:outline-none focus-visible:bg-white/10"
|
aria-label={`${track.title} 대기열에 추가`}
|
||||||
|
className="group flex w-full items-center gap-4 py-2 text-left focus:outline-none focus-visible:bg-white/10 disabled:cursor-wait"
|
||||||
>
|
>
|
||||||
<span className={`relative flex-shrink-0 ${wide ? "h-14 w-[100px]" : "h-14 w-14"}`}>
|
<span className={`relative flex-shrink-0 ${wide ? "h-14 w-[100px]" : "h-14 w-14"}`}>
|
||||||
<Thumb src={track.thumbnail} wide={wide} className="h-full w-full" />
|
<Thumb src={track.thumbnail} wide={wide} className="h-full w-full" />
|
||||||
{/* 썸네일 위 재생 아이콘: 마우스면 hover 때, 터치 화면이면 항상 */}
|
{/* 썸네일 위 재생 아이콘: 마우스면 hover 때, 터치 화면이면 항상 */}
|
||||||
<span className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 pointer-coarse:opacity-100 pointer-coarse:bg-black/30">
|
<span className={`absolute inset-0 flex items-center justify-center bg-black/50 transition-opacity ${pending ? "opacity-100" : "opacity-0 group-hover:opacity-100 pointer-coarse:opacity-100 pointer-coarse:bg-black/30"}`}>
|
||||||
<Play fill="white" stroke="white" size={20} aria-hidden />
|
{pending ? <Spinner size={20} label="추가 중" /> : <Play fill="white" stroke="white" size={20} aria-hidden />}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
@@ -213,72 +198,24 @@ export default function MainContent({
|
|||||||
<main className="relative flex-1 overflow-y-auto bg-yt-bg">
|
<main className="relative flex-1 overflow-y-auto bg-yt-bg">
|
||||||
<div className="mx-auto w-full max-w-[1200px] px-4 pb-32 pt-6 sm:px-8 lg:px-12">
|
<div className="mx-auto w-full max-w-[1200px] px-4 pb-32 pt-6 sm:px-8 lg:px-12">
|
||||||
|
|
||||||
{/* 화면 1: 홈(서버 고르기) — 유튜브 뮤직 홈의 아티스트 줄 형태 */}
|
{/* 서버 홈 — 유튜브 뮤직 앨범 페이지 형태(왼쪽 서버 정보, 오른쪽 재생 목록) */}
|
||||||
{viewMode === "SERVER_LIST" && (
|
{viewMode === "HOME" && (
|
||||||
<section>
|
|
||||||
<div className="mb-6 flex items-center gap-3">
|
|
||||||
{session?.user?.image ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img src={session.user.image} alt="" className="h-10 w-10 rounded-full object-cover" />
|
|
||||||
) : null}
|
|
||||||
<div>
|
|
||||||
<p className="text-sm uppercase tracking-wide text-white/70">{session?.user?.name}</p>
|
|
||||||
<h2 className="text-2xl font-bold sm:text-[32px] sm:leading-tight">어느 서버에서 들을까요?</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isFetchingServers && servers.length === 0 ? (
|
|
||||||
<div className="flex justify-center py-24">
|
|
||||||
<Spinner label="서버 목록을 불러오는 중" />
|
|
||||||
</div>
|
|
||||||
) : servers.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center gap-2 py-24 text-center">
|
|
||||||
<p className="text-base text-white">표시할 서버가 없습니다.</p>
|
|
||||||
<p className="max-w-sm text-sm text-white/50">
|
|
||||||
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
|
||||||
{servers.map((server) => (
|
|
||||||
<li key={server.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelectServer(server)}
|
|
||||||
className="group w-full text-center focus:outline-none"
|
|
||||||
>
|
|
||||||
<span className="relative mb-3 block">
|
|
||||||
<ServerAvatar server={server} className="aspect-square w-full" iconSize={40} size={256} />
|
|
||||||
<span className="absolute inset-0 rounded-full bg-black/0 transition-colors group-hover:bg-black/30 group-focus-visible:ring-2 group-focus-visible:ring-white" />
|
|
||||||
</span>
|
|
||||||
<span className="block truncate text-base font-medium text-white">{server.name}</span>
|
|
||||||
<span className="block truncate text-sm text-white/70">{permissionLabel(server)}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 화면 2: 서버 페이지 — 유튜브 뮤직 앨범/재생목록 페이지 형태(왼쪽 커버, 오른쪽 트랙 목록) */}
|
|
||||||
{viewMode === "SERVER_DETAIL" && selectedServer && (
|
|
||||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:gap-12">
|
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:gap-12">
|
||||||
<section className="flex flex-col items-center text-center lg:sticky lg:top-6 lg:w-[320px] lg:flex-shrink-0">
|
<section className="flex flex-col items-center text-center lg:sticky lg:top-6 lg:w-[320px] lg:flex-shrink-0">
|
||||||
<ServerAvatar
|
<ServerAvatar
|
||||||
server={selectedServer}
|
server={server}
|
||||||
rounded="rounded-lg"
|
rounded="rounded-lg"
|
||||||
className="h-52 w-52 shadow-2xl sm:h-64 sm:w-64"
|
className="h-32 w-32 shadow-2xl sm:h-60 sm:w-60"
|
||||||
iconSize={64}
|
iconSize={64}
|
||||||
size={512}
|
size={512}
|
||||||
/>
|
/>
|
||||||
<h1 className="mt-6 break-words text-[28px] font-bold leading-tight sm:text-[32px]">{selectedServer.name}</h1>
|
<h1 className="mt-6 break-words text-[28px] font-bold leading-tight sm:text-[32px]">{server.name || "서버"}</h1>
|
||||||
<p className="mt-2 text-sm text-white/70">
|
<p className="mt-2 text-sm text-white/70">디스코드 서버 • 내 권한: {permissionLabel(server)}</p>
|
||||||
디스코드 서버 • 내 권한: {permissionLabel(selectedServer)}
|
<p className="mt-1 flex items-center justify-center gap-1.5 text-sm text-white/60">
|
||||||
</p>
|
<Headphones size={14} aria-hidden />
|
||||||
<p className="mt-4 max-w-sm text-sm leading-relaxed text-white/70">
|
{player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "봇이 아직 음성 채널에 없어요"}
|
||||||
먼저 디스코드에서 음성 채널에 입장한 뒤, 노래를 검색해 누르면 봇이 재생합니다.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
<div className="mt-6 flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -298,7 +235,36 @@ export default function MainContent({
|
|||||||
<ListMusic size={20} aria-hidden />
|
<ListMusic size={20} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 break-all font-mono text-[11px] text-white/30">ID {selectedServer.id}</p>
|
|
||||||
|
{/* 처음 쓰는 사람을 위한 안내: 아무것도 재생 중이 아닐 때만 */}
|
||||||
|
{!player.track && (
|
||||||
|
<ol className="mt-6 w-full max-w-sm space-y-2 rounded-xl bg-white/[0.06] p-4 text-left text-sm text-white/80">
|
||||||
|
<li>1. 디스코드에서 <strong className="text-white">음성 채널에 먼저 입장</strong>해 주세요.</li>
|
||||||
|
<li>2. 위 검색창에서 노래를 찾거나 유튜브·스포티파이 <strong className="text-white">주소를 붙여넣고</strong> Enter.</li>
|
||||||
|
<li>3. 곡을 누르면 봇이 들어와서 바로 재생해요.</li>
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 최근 검색: 다시 찾기 쉽게 칩으로 */}
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<div className="mt-6 w-full max-w-sm text-left">
|
||||||
|
<p className="mb-2 flex items-center gap-1.5 text-xs font-medium text-white/60">
|
||||||
|
<Clock size={14} aria-hidden /> 최근 검색
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{recent.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSearch(r)}
|
||||||
|
className="h-8 max-w-full truncate rounded-lg bg-yt-chip px-3 text-sm text-white transition-colors hover:bg-yt-chip-hover"
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="min-w-0 flex-1">
|
<section className="min-w-0 flex-1">
|
||||||
@@ -307,10 +273,37 @@ export default function MainContent({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 화면 3: 검색 결과 — 유튜브 뮤직 검색 화면(칩 필터 + 상위 검색결과 + 구역별 목록) */}
|
{/* 주소를 붙여넣은 경우: 검색 대신 바로 불러오기 카드 */}
|
||||||
{viewMode === "SEARCH_RESULT" && (
|
{viewMode === "SEARCH" && isUrlQuery && (
|
||||||
|
<section className="mx-auto max-w-2xl">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold">주소에서 불러오기</h2>
|
||||||
|
<div className="rounded-lg bg-white/[0.08] p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-white/10">
|
||||||
|
<Link2 size={22} aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{isCollectionUrl(searchQuery) ? "재생목록 · 앨범" : "곡 주소"}</p>
|
||||||
|
<p className="mt-1 break-all text-sm text-white/60">{searchQuery}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addUrl(searchQuery)}
|
||||||
|
disabled={!!pendingKey}
|
||||||
|
className="mt-5 flex h-10 items-center gap-2 rounded-full bg-white px-5 text-sm font-medium text-black transition-colors hover:bg-white/85 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pendingKey === "url" ? <Spinner size={18} label="불러오는 중" /> : <ListPlus size={18} aria-hidden />}
|
||||||
|
{pendingKey === "url" ? "불러오는 중..." : "대기열에 추가"}
|
||||||
|
</button>
|
||||||
|
<p className="mt-3 text-xs text-white/50">유튜브·유튜브 뮤직·스포티파이의 곡, 재생목록, 앨범 주소를 지원해요.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검색 결과 — 유튜브 뮤직 검색 화면(칩 필터 + 상위 검색결과 + 구역별 목록) */}
|
||||||
|
{viewMode === "SEARCH" && !isUrlQuery && (
|
||||||
<div>
|
<div>
|
||||||
{/* 필터 칩 */}
|
|
||||||
<div className="no-scrollbar -mx-4 mb-6 flex gap-3 overflow-x-auto px-4 sm:mx-0 sm:px-0">
|
<div className="no-scrollbar -mx-4 mb-6 flex gap-3 overflow-x-auto px-4 sm:mx-0 sm:px-0">
|
||||||
{([{ key: "all", title: "전체" } as const, ...SOURCES]).map((chip) => {
|
{([{ key: "all", title: "전체" } as const, ...SOURCES]).map((chip) => {
|
||||||
const active = filter === chip.key;
|
const active = filter === chip.key;
|
||||||
@@ -326,6 +319,7 @@ export default function MainContent({
|
|||||||
${active ? "bg-white text-black" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`}
|
${active ? "bg-white text-black" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`}
|
||||||
>
|
>
|
||||||
{chip.title}
|
{chip.title}
|
||||||
|
{chip.key !== "all" && counts[chip.key] > 0 && <span className="ml-1 opacity-60">{counts[chip.key]}</span>}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -336,6 +330,18 @@ export default function MainContent({
|
|||||||
<Spinner label="노래를 찾는 중" />
|
<Spinner label="노래를 찾는 중" />
|
||||||
<p className="text-sm">“{searchQuery}” 검색 중...</p>
|
<p className="text-sm">“{searchQuery}” 검색 중...</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : searchError ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 py-24 text-center">
|
||||||
|
<SearchX size={48} className="text-white/50" aria-hidden />
|
||||||
|
<p className="text-base text-white">{searchError}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRetry((n) => n + 1)}
|
||||||
|
className="h-9 rounded-full border border-white/20 px-4 text-sm hover:bg-white/10"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : !hasAnyResults ? (
|
) : !hasAnyResults ? (
|
||||||
<div className="flex flex-col items-center gap-3 py-24 text-center">
|
<div className="flex flex-col items-center gap-3 py-24 text-center">
|
||||||
<SearchX size={48} className="text-white/50" aria-hidden />
|
<SearchX size={48} className="text-white/50" aria-hidden />
|
||||||
@@ -344,7 +350,6 @@ export default function MainContent({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-10">
|
||||||
{/* 상위 검색결과 카드 */}
|
|
||||||
{filter === "all" && topTrack && topSource && (
|
{filter === "all" && topTrack && topSource && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-4 text-2xl font-bold">상위 검색결과</h2>
|
<h2 className="mb-4 text-2xl font-bold">상위 검색결과</h2>
|
||||||
@@ -368,10 +373,11 @@ export default function MainContent({
|
|||||||
<div className="flex gap-2 sm:ml-auto">
|
<div className="flex gap-2 sm:ml-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleMusicAction('player_play', topTrack)}
|
onClick={() => addTrack(topTrack, "top")}
|
||||||
className="flex h-9 items-center gap-2 rounded-full bg-white px-4 text-sm font-medium text-black transition-colors hover:bg-white/85"
|
disabled={!!pendingKey}
|
||||||
|
className="flex h-9 items-center gap-2 rounded-full bg-white px-4 text-sm font-medium text-black transition-colors hover:bg-white/85 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<Play size={18} fill="currentColor" aria-hidden />
|
{pendingKey === "top" ? <Spinner size={16} label="추가 중" /> : <Play size={18} fill="currentColor" aria-hidden />}
|
||||||
재생
|
재생
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +402,7 @@ export default function MainContent({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ul>{shown.map((t) => renderTrackRow(t, s.kind, s.wide))}</ul>
|
<ul>{shown.map((t, i) => renderTrackRow(t, s.kind, s.wide, s.key, i))}</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ChevronDown, ChevronUp, Pause, Play, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
|
import { ChevronDown, ChevronUp, Pause, Play, Shuffle, SkipBack, SkipForward, Volume2, VolumeX } from "lucide-react";
|
||||||
import { Thumb } from "@/components/ui";
|
import { Thumb } from "@/components/ui";
|
||||||
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
|
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
|
||||||
|
|
||||||
interface PlayerBarProps {
|
interface PlayerBarProps {
|
||||||
player: PlayerState;
|
player: PlayerState;
|
||||||
|
onShuffle: () => void;
|
||||||
|
canShuffle: boolean;
|
||||||
isPlayerOpen: boolean;
|
isPlayerOpen: boolean;
|
||||||
onTogglePlayer: () => void;
|
onTogglePlayer: () => void;
|
||||||
}
|
}
|
||||||
@@ -49,10 +51,10 @@ export function SeekBar({ player, className = "", thick = false }: { player: Pla
|
|||||||
* 넓은 화면: [이전 ▶ 다음 0:12/3:45] [썸네일 제목·아티스트] [볼륨 ⌃]
|
* 넓은 화면: [이전 ▶ 다음 0:12/3:45] [썸네일 제목·아티스트] [볼륨 ⌃]
|
||||||
* 좁은 화면: 미니 플레이어 [썸네일 제목] [▶ 다음] — 누르면 재생 화면이 열린다.
|
* 좁은 화면: 미니 플레이어 [썸네일 제목] [▶ 다음] — 누르면 재생 화면이 열린다.
|
||||||
*/
|
*/
|
||||||
export default function PlayerBar({ player, isPlayerOpen, onTogglePlayer }: PlayerBarProps) {
|
export default function PlayerBar({ player, onShuffle, canShuffle, isPlayerOpen, onTogglePlayer }: PlayerBarProps) {
|
||||||
const {
|
const {
|
||||||
track, isPaused, volume, position, duration, canControl, isVolumeDragging,
|
track, isPaused, volume, position, duration, canControl, isVolumeDragging,
|
||||||
togglePause, skip, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
|
togglePause, skip, restart, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
|
||||||
} = player;
|
} = player;
|
||||||
|
|
||||||
const title = track?.info?.title || "재생 중인 곡 없음";
|
const title = track?.info?.title || "재생 중인 곡 없음";
|
||||||
@@ -94,10 +96,11 @@ export default function PlayerBar({ player, isPlayerOpen, onTogglePlayer }: Play
|
|||||||
<div className="flex flex-shrink-0 items-center gap-2 lg:w-[292px]">
|
<div className="flex flex-shrink-0 items-center gap-2 lg:w-[292px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
onClick={restart}
|
||||||
title="이전 곡으로 돌아가는 기능은 아직 지원하지 않습니다"
|
disabled={!canControl}
|
||||||
aria-label="이전 곡 (지원 예정)"
|
title="처음부터 다시 재생"
|
||||||
className="flex h-10 w-10 cursor-not-allowed items-center justify-center rounded-full text-white/30"
|
aria-label="처음부터 다시 재생"
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full text-white transition-colors hover:bg-white/10 disabled:cursor-not-allowed disabled:text-white/30"
|
||||||
>
|
>
|
||||||
<SkipBack size={24} fill="currentColor" aria-hidden />
|
<SkipBack size={24} fill="currentColor" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
@@ -169,6 +172,17 @@ export default function PlayerBar({ player, isPlayerOpen, onTogglePlayer }: Play
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShuffle}
|
||||||
|
disabled={!canShuffle}
|
||||||
|
aria-label="다음 트랙 섞기"
|
||||||
|
title="다음 트랙 섞기"
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-full text-white/70 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Shuffle size={22} aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onTogglePlayer}
|
onClick={onTogglePlayer}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface PlayerPageProps {
|
|||||||
export default function PlayerPage({ player, queue, onClose }: PlayerPageProps) {
|
export default function PlayerPage({ player, queue, onClose }: PlayerPageProps) {
|
||||||
const {
|
const {
|
||||||
track, isPaused, volume, position, duration, canControl,
|
track, isPaused, volume, position, duration, canControl,
|
||||||
togglePause, skip, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
|
togglePause, skip, restart, beginVolumeDrag, dragVolume, commitVolume, toggleMute,
|
||||||
} = player;
|
} = player;
|
||||||
const art = track?.info?.artworkUrl;
|
const art = track?.info?.artworkUrl;
|
||||||
|
|
||||||
@@ -82,7 +82,9 @@ export default function PlayerPage({ player, queue, onClose }: PlayerPageProps)
|
|||||||
>
|
>
|
||||||
<ChevronDown size={28} aria-hidden />
|
<ChevronDown size={28} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
<span className="flex-1 pr-11 text-center text-sm font-medium text-white/70">지금 재생 중</span>
|
<span className="min-w-0 flex-1 truncate pr-11 text-center text-sm font-medium text-white/70">
|
||||||
|
{player.voiceChannelName ? `${player.voiceChannelName}에서 재생 중` : "지금 재생 중"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6">
|
<div className="px-6">
|
||||||
@@ -107,10 +109,10 @@ export default function PlayerPage({ player, queue, onClose }: PlayerPageProps)
|
|||||||
<div className="mt-2 flex items-center justify-center gap-6">
|
<div className="mt-2 flex items-center justify-center gap-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
onClick={restart}
|
||||||
aria-label="이전 곡 (지원 예정)"
|
disabled={!canControl}
|
||||||
title="이전 곡으로 돌아가는 기능은 아직 지원하지 않습니다"
|
aria-label="처음부터 다시 재생"
|
||||||
className="flex h-12 w-12 cursor-not-allowed items-center justify-center text-white/30"
|
className="flex h-12 w-12 items-center justify-center text-white disabled:text-white/30"
|
||||||
>
|
>
|
||||||
<SkipBack size={30} fill="currentColor" aria-hidden />
|
<SkipBack size={30} fill="currentColor" aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { ChevronDown, ChevronUp, GripVertical, Play, Trash2 } from "lucide-react";
|
import { ChevronDown, ChevronUp, GripVertical, ListX, Play, Shuffle, Trash2 } from "lucide-react";
|
||||||
import { Equalizer, Thumb } from "@/components/ui";
|
import { Equalizer, Thumb } from "@/components/ui";
|
||||||
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
|
import { cleanArtist, formatTime, type PlayerState } from "@/hooks/usePlayer";
|
||||||
import type { QueueState } from "@/hooks/useQueue";
|
import type { QueueState } from "@/hooks/useQueue";
|
||||||
@@ -20,8 +20,19 @@ interface QueueListProps {
|
|||||||
* - 터치 화면: hover 가 없으니 ▲▼·삭제 버튼을 항상 보여준다
|
* - 터치 화면: hover 가 없으니 ▲▼·삭제 버튼을 항상 보여준다
|
||||||
*/
|
*/
|
||||||
export default function QueueList({ player, queue, variant }: QueueListProps) {
|
export default function QueueList({ player, queue, variant }: QueueListProps) {
|
||||||
const { track: nowPlaying, isPaused } = player;
|
const { track: nowPlaying, isPaused, autoplay, toggleAutoplay } = player;
|
||||||
const { queue: items, moveTo, moveBy, playFrom, remove } = queue;
|
const { queue: items, moveTo, moveBy, playFrom, remove, shuffle, clear } = queue;
|
||||||
|
|
||||||
|
// "모두 지우기"는 되돌릴 수 없어서 한 번 더 눌러야 실행한다(3초 안에).
|
||||||
|
const [confirmClear, setConfirmClear] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirmClear) return;
|
||||||
|
const t = setTimeout(() => setConfirmClear(false), 3000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [confirmClear]);
|
||||||
|
|
||||||
|
const totalMs = items.reduce((sum, t) => sum + (Number(t.info?.length) || 0), 0);
|
||||||
|
const totalMin = Math.round(totalMs / 60000);
|
||||||
|
|
||||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
@@ -46,14 +57,65 @@ export default function QueueList({ player, queue, variant }: QueueListProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isPage ? "" : "pb-6"}>
|
<div className={isPage ? "" : "pb-6"}>
|
||||||
{isPage && (
|
{/* 머리말: 곡 수·총 길이 + 섞기 / 모두 지우기 */}
|
||||||
<div className="mb-2 flex items-baseline justify-between gap-2 border-b border-yt-line pb-3">
|
<div className={`mb-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-2 ${isPage ? "border-b border-yt-line pb-3" : "px-2"}`}>
|
||||||
<h2 className="text-2xl font-bold">재생 목록</h2>
|
<div className="min-w-0">
|
||||||
<span className="text-sm text-white/50">
|
{isPage && <h2 className="text-2xl font-bold">재생 목록</h2>}
|
||||||
{items.length > 0 ? `다음 트랙 ${items.length}곡` : ""}
|
<p className="text-sm text-white/60">
|
||||||
</span>
|
{items.length > 0
|
||||||
|
? `다음 트랙 ${items.length}곡${totalMin > 0 ? ` · 약 ${totalMin}분` : ""}`
|
||||||
|
: "다음 트랙 없음"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={shuffle}
|
||||||
|
disabled={items.length < 2}
|
||||||
|
title="다음 트랙 섞기"
|
||||||
|
className="flex h-8 items-center gap-1.5 rounded-full bg-yt-chip px-3 text-sm text-white transition-colors hover:bg-yt-chip-hover disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Shuffle size={16} aria-hidden />
|
||||||
|
섞기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirmClear) {
|
||||||
|
setConfirmClear(false);
|
||||||
|
clear();
|
||||||
|
} else setConfirmClear(true);
|
||||||
|
}}
|
||||||
|
disabled={items.length === 0}
|
||||||
|
title="다음 트랙 모두 지우기(지금 곡은 계속 재생)"
|
||||||
|
className={`flex h-8 items-center gap-1.5 rounded-full px-3 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40
|
||||||
|
${confirmClear ? "bg-yt-red text-white" : "bg-yt-chip text-white hover:bg-yt-chip-hover"}`}
|
||||||
|
>
|
||||||
|
<ListX size={16} aria-hidden />
|
||||||
|
{confirmClear ? "한 번 더 누르면 지워요" : "모두 지우기"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자동재생(유튜브 뮤직 "다음 트랙" 위의 스위치와 같은 자리) */}
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-4 rounded-md px-2 py-2">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium text-white">자동재생</span>
|
||||||
|
<span className="block text-xs text-white/60">대기열이 끝나면 비슷한 곡을 이어서 틀어요</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={autoplay}
|
||||||
|
aria-label="자동재생"
|
||||||
|
onClick={toggleAutoplay}
|
||||||
|
className={`relative h-6 w-11 flex-shrink-0 rounded-full transition-colors ${autoplay ? "bg-[#3ea6ff]" : "bg-white/30"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute left-0 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${autoplay ? "translate-x-[22px]" : "translate-x-0.5"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 지금 재생 중 */}
|
{/* 지금 재생 중 */}
|
||||||
{nowPlaying && (
|
{nowPlaying && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useToast } from "@/components/ToastProvider";
|
||||||
import type { DiscordServer, Track } from "@/types/music";
|
import type { DiscordServer, Track } from "@/types/music";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,6 +10,7 @@ import type { DiscordServer, Track } from "@/types/music";
|
|||||||
*/
|
*/
|
||||||
export function usePlayer(selectedServer: DiscordServer | null) {
|
export function usePlayer(selectedServer: DiscordServer | null) {
|
||||||
const { data: session } = useSession();
|
const { data: session } = useSession();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
// 재생 상태
|
// 재생 상태
|
||||||
const [track, setTrack] = useState<Track | null>(null);
|
const [track, setTrack] = useState<Track | null>(null);
|
||||||
@@ -16,6 +18,9 @@ export function usePlayer(selectedServer: DiscordServer | null) {
|
|||||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
const [isPaused, setIsPaused] = useState<boolean>(false);
|
const [isPaused, setIsPaused] = useState<boolean>(false);
|
||||||
const [volume, setVolume] = useState<number>(50);
|
const [volume, setVolume] = useState<number>(50);
|
||||||
|
// 자동재생(대기열이 끝나면 비슷한 곡 이어 재생) · 봇이 들어가 있는 음성채널 이름
|
||||||
|
const [autoplay, setAutoplay] = useState<boolean>(false);
|
||||||
|
const [voiceChannelName, setVoiceChannelName] = useState<string | null>(null);
|
||||||
|
|
||||||
// 시간 및 진행도
|
// 시간 및 진행도
|
||||||
const [position, setPosition] = useState<number>(0);
|
const [position, setPosition] = useState<number>(0);
|
||||||
@@ -46,6 +51,12 @@ export function usePlayer(selectedServer: DiscordServer | null) {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
// 봇이 옛 버전이면 이 두 값이 없을 수 있다 → 있을 때만 반영
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (typeof data.recommend === "boolean") setAutoplay(data.recommend);
|
||||||
|
setVoiceChannelName(typeof data.voiceChannelName === "string" ? data.voiceChannelName : null);
|
||||||
|
}
|
||||||
|
|
||||||
if (res.ok && data.success && data.track) {
|
if (res.ok && data.success && data.track) {
|
||||||
setBotPlayer(Boolean(data.botPlayer));
|
setBotPlayer(Boolean(data.botPlayer));
|
||||||
setIsPlaying(Boolean(data.isPlaying));
|
setIsPlaying(Boolean(data.isPlaying));
|
||||||
@@ -178,6 +189,37 @@ export function usePlayer(selectedServer: DiscordServer | null) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 이전 곡 버튼: 유튜브 뮤직처럼 지금 곡을 처음부터 다시 재생(봇에 이전 곡 기록이 없어서) */
|
||||||
|
const restart = () => {
|
||||||
|
if (!canControl) return;
|
||||||
|
commitSeek(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 자동재생 켜기/끄기 */
|
||||||
|
const toggleAutoplay = async () => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
const next = !autoplay;
|
||||||
|
setAutoplay(next); // 낙관적 반영
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/autoplay', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serverId: selectedServer.id }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (typeof data.recommend === "boolean") setAutoplay(data.recommend);
|
||||||
|
showToast(data.recommend ?? next ? "자동재생을 켰어요. 대기열이 끝나면 비슷한 곡을 이어서 틀어요." : "자동재생을 껐어요.", "success");
|
||||||
|
} else {
|
||||||
|
setAutoplay(!next);
|
||||||
|
showToast(data.message || data.error || "자동재생을 바꾸지 못했습니다.", "error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setAutoplay(!next);
|
||||||
|
showToast("서버와 통신할 수 없습니다.", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** 진행바를 끄는 중: 화면만 갱신하고 서버 동기화는 멈춘다. */
|
/** 진행바를 끄는 중: 화면만 갱신하고 서버 동기화는 멈춘다. */
|
||||||
const dragSeek = (ms: number) => {
|
const dragSeek = (ms: number) => {
|
||||||
isDragging.current = true;
|
isDragging.current = true;
|
||||||
@@ -301,7 +343,11 @@ export function usePlayer(selectedServer: DiscordServer | null) {
|
|||||||
duration,
|
duration,
|
||||||
canControl,
|
canControl,
|
||||||
isVolumeDragging,
|
isVolumeDragging,
|
||||||
|
autoplay,
|
||||||
|
voiceChannelName,
|
||||||
togglePause,
|
togglePause,
|
||||||
|
restart,
|
||||||
|
toggleAutoplay,
|
||||||
skip,
|
skip,
|
||||||
dragSeek,
|
dragSeek,
|
||||||
commitSeek,
|
commitSeek,
|
||||||
|
|||||||
@@ -198,7 +198,38 @@ export function useQueue(selectedServer: DiscordServer | null) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { queue, moveTo, moveBy, playFrom, remove };
|
/** 다음 트랙 섞기(봇이 섞고 queue_update 로 새 순서를 알려준다) */
|
||||||
|
const shuffle = async () => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
if (queue.length < 2) {
|
||||||
|
showToast("섞을 곡이 2곡 이상 있어야 해요.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/queue/shuffle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serverId: selectedServer.id }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
showToast("다음 트랙을 섞었어요.", "success");
|
||||||
|
fetchQueue();
|
||||||
|
} else {
|
||||||
|
showToast(data.message || data.error || "대기열을 섞지 못했습니다.", "error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("서버와 통신할 수 없습니다.", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 다음 트랙 모두 지우기(지금 곡은 계속 재생) */
|
||||||
|
const clear = async () => {
|
||||||
|
if (queue.length === 0) return;
|
||||||
|
await applyReorder([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { queue, moveTo, moveBy, playFrom, remove, shuffle, clear };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueueState = ReturnType<typeof useQueue>;
|
export type QueueState = ReturnType<typeof useQueue>;
|
||||||
|
|||||||
@@ -1,45 +1,85 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import type { DiscordServer } from "@/types/music";
|
import type { DiscordServer } from "@/types/music";
|
||||||
|
|
||||||
/** 봇이 들어가 있는 내 서버 목록. 홈 화면과 왼쪽 메뉴가 함께 쓴다. */
|
const CACHE_KEY = "filtered_servers";
|
||||||
|
const LAST_SERVER_KEY = "musicbot:last-server";
|
||||||
|
|
||||||
|
/** 봇이 들어가 있는 내 서버 목록(세션 캐시를 먼저 보여주고 뒤에서 새로 받는다). */
|
||||||
export function useServers() {
|
export function useServers() {
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const [servers, setServers] = useState<DiscordServer[]>([]);
|
const [servers, setServers] = useState<DiscordServer[]>([]);
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
/** 캐시든 새로 받은 것이든 목록을 한 번이라도 손에 쥐었는지 */
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const load = useCallback((useCache: boolean) => {
|
||||||
if (status !== "authenticated") return;
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
const cached = sessionStorage.getItem("filtered_servers");
|
setError(null);
|
||||||
if (cached) {
|
if (useCache) {
|
||||||
try {
|
const cached = sessionStorage.getItem(CACHE_KEY);
|
||||||
const parsed = JSON.parse(cached);
|
if (cached) {
|
||||||
if (Array.isArray(parsed)) {
|
try {
|
||||||
setServers(parsed);
|
const parsed = JSON.parse(cached);
|
||||||
setIsFetching(false);
|
if (Array.isArray(parsed)) {
|
||||||
|
setServers(parsed);
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
|
||||||
|
sessionStorage.removeItem(CACHE_KEY);
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
|
|
||||||
sessionStorage.removeItem("filtered_servers");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch("/api/servers")
|
fetch("/api/servers")
|
||||||
.then((res) => res.json())
|
.then(async (res) => {
|
||||||
.then((data) => {
|
const data = await res.json().catch(() => null);
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setServers(data);
|
setServers(data);
|
||||||
sessionStorage.setItem("filtered_servers", JSON.stringify(data));
|
sessionStorage.setItem(CACHE_KEY, JSON.stringify(data));
|
||||||
|
} else {
|
||||||
|
setError((data && (data.error || data.message)) || "서버 목록을 가져오지 못했습니다.");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => console.error("서버 목록 불러오기 실패:", err))
|
.catch(() => setError("서버와 통신할 수 없습니다."))
|
||||||
.finally(() => setIsFetching(false));
|
.finally(() => {
|
||||||
}, [status]);
|
setIsFetching(false);
|
||||||
|
setLoaded(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return { servers, isFetching };
|
useEffect(() => {
|
||||||
|
if (status !== "authenticated") return;
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
load(true);
|
||||||
|
}, [status, load]);
|
||||||
|
|
||||||
|
/** 캐시를 버리고 디스코드에서 다시 받아온다(봇을 막 초대했을 때 등). */
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
sessionStorage.removeItem(CACHE_KEY);
|
||||||
|
load(false);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return { servers, isFetching, error, loaded, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마지막으로 연 서버(서버 선택 화면에서 맨 앞에 "최근" 표시) */
|
||||||
|
export function getLastServerId(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(LAST_SERVER_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function setLastServerId(id: string) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LAST_SERVER_KEY, id);
|
||||||
|
} catch {
|
||||||
|
// 저장 불가(사생활 보호 모드 등)면 무시
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 디스코드 서버 아이콘 URL(없으면 null) */
|
/** 디스코드 서버 아이콘 URL(없으면 null) */
|
||||||
|
|||||||
21
page/src/lib/auth.ts
Normal file
21
page/src/lib/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { getServerSession, type Session } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||||
|
|
||||||
|
/** 서버 컴포넌트용: 로그인 안 돼 있으면 로그인 페이지로 보낸다(돌아올 주소를 함께 넘긴다). */
|
||||||
|
export async function requirePageSession(callbackUrl: string): Promise<Session> {
|
||||||
|
const session = (await getServerSession(authOptions)) as Session | null;
|
||||||
|
if (!session?.user?.id) redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인 여부만 확인(로그인 페이지에서 이미 로그인된 사람을 넘길 때) */
|
||||||
|
export async function getPageSession(): Promise<Session | null> {
|
||||||
|
return (await getServerSession(authOptions)) as Session | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인 후 돌아갈 주소: 같은 사이트 경로만 허용(외부 주소로 튕기는 오픈 리다이렉트 방지) */
|
||||||
|
export function safeCallbackUrl(raw: unknown, fallback = "/servers") {
|
||||||
|
if (typeof raw !== "string" || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/login")) return fallback;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
24
page/src/lib/recent.ts
Normal file
24
page/src/lib/recent.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/** 최근 검색어(브라우저에만 저장). 상단 검색창 목록과 서버 홈의 "최근 검색" 칩이 같이 쓴다. */
|
||||||
|
const RECENT_KEY = "musicbot:recent-searches";
|
||||||
|
export const RECENT_MAX = 8;
|
||||||
|
|
||||||
|
export function readRecent(): string[] {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(RECENT_KEY) ?? "[]");
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").slice(0, RECENT_MAX) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeRecent(list: string[]) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENT_KEY, JSON.stringify(list.slice(0, RECENT_MAX)));
|
||||||
|
window.dispatchEvent(new Event(RECENT_EVENT));
|
||||||
|
} catch {
|
||||||
|
// 저장 불가면 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 최근 검색어가 바뀌었음을 같은 탭의 다른 컴포넌트에 알린다 */
|
||||||
|
export const RECENT_EVENT = "musicbot:recent-changed";
|
||||||
36
page/src/lib/url.ts
Normal file
36
page/src/lib/url.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* 검색창에 붙여넣은 값이 봇이 바로 불러올 수 있는 음악 주소인지 판단한다.
|
||||||
|
* (유튜브·유튜브 뮤직·스포티파이 — 곡/재생목록/앨범 모두 봇의 resolveQuery 가 처리)
|
||||||
|
*/
|
||||||
|
const MUSIC_HOSTS = [
|
||||||
|
"youtube.com",
|
||||||
|
"www.youtube.com",
|
||||||
|
"m.youtube.com",
|
||||||
|
"music.youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
"open.spotify.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isMusicUrl(value: string): boolean {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!/^https?:\/\//i.test(v)) return false;
|
||||||
|
try {
|
||||||
|
return MUSIC_HOSTS.includes(new URL(v).hostname.toLowerCase());
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 재생목록·앨범처럼 여러 곡을 담은 주소인지(안내 문구용) */
|
||||||
|
export function isCollectionUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(value.trim());
|
||||||
|
return (
|
||||||
|
u.searchParams.has("list") ||
|
||||||
|
/\/(playlist|album|artist)\//.test(u.pathname) ||
|
||||||
|
u.pathname === "/playlist"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user