지금까지 내용 커밋

This commit is contained in:
2026-04-08 12:59:45 +09:00
commit b0dae31cb9
68 changed files with 12083 additions and 0 deletions

41
page/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
page/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
page/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
page/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
page/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

11
page/next.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
allowedDevOrigins: [
"192.168.10.13",
"localhost",
]
};
export default nextConfig;

6949
page/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
page/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "music_bot_v2_site",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"colors": "^1.4.0",
"ioredis": "^5.10.1",
"lucide-react": "^1.7.0",
"next": "16.2.2",
"next-auth": "^4.24.13",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
page/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,34 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import DiscordProvider from "next-auth/providers/discord";
export const authOptions: NextAuthOptions = {
providers: [
DiscordProvider({
clientId: process.env.DISCORD_CLIENT_ID as string,
clientSecret: process.env.DISCORD_CLIENT_SECRET as string,
// 🌟 핵심: 로그인할 때 유저의 기본 정보(identify)와 서버 목록(guilds) 권한을 같이 가져옵니다!
authorization: { params: { scope: "identify email guilds" } },
}),
],
session: {
strategy: "jwt",
},
callbacks: {
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
async jwt({ token, account }) {
if (account) {
token.accessToken = account.access_token;
}
return token;
},
async session({ session, token }: any) {
session.accessToken = token.accessToken;
return session;
},
},
};
const handler = NextAuth(authOptions);
// App Router 환경에서는 GET과 POST 메서드를 둘 다 내보내야 합니다.
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,43 @@
// src/app/api/search/route.ts
import { NextResponse } from "next/server";
import { Redis } from "@/lib/Redis";
export async function GET(request: Request) {
// 1. 검색어(query) 가져오기
const { searchParams } = new URL(request.url);
const query = searchParams.get("q");
if (!query) {
return NextResponse.json({ error: "검색어가 없습니다." }, { status: 400 });
}
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const resultKey = `search:result:${requestId}`;
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
await Redis.publish("site-bot", JSON.stringify({
action: "search",
query: query,
requestId: requestId,
}));
// 4. 결과가 올라올 때까지 기다리기 (Polling)
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
for (let i=0; i<10; i++) {
// 0.5초 대기
await new Promise(resolve => setTimeout(resolve, 500));
// Redis 게시판 확인
const resultData = await Redis.get(resultKey);
console.log(resultData);
if (resultData) {
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료
return NextResponse.json(JSON.parse(resultData));
}
}
// 5초가 지나도 응답이 없으면 타임아웃
return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 });
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { Redis } from "@/lib/Redis";
import { authOptions } from "../auth/[...nextauth]/route";
export async function GET() {
const session = await getServerSession(authOptions) as any;
if (!session || !session.accessToken) {
return NextResponse.json({ error: "인증되지 않았습니다." }, { status: 401 });
}
try {
// 1. 디스코드 API에서 유저가 속한 서버 목록 가져오기
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const userGuilds = await userGuildsRes.json();
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
const botGuildsData = await Redis.get("bot-guilds");
const botGuildIds: string[] = botGuildsData ? JSON.parse(botGuildsData) : [];
// 3. 🌟 두 목록을 비교해서 봇이 있는 서버만 필터링!
const filteredGuilds = userGuilds.filter((guild: any) =>
botGuildIds.includes(guild.id)
);
return NextResponse.json(filteredGuilds);
} catch (error) {
console.error("서버 필터링 에러:", error);
return NextResponse.json({ error: "서버 목록을 가져오지 못했습니다." }, { status: 500 });
}
}

30
page/src/app/globals.css Normal file
View File

@@ -0,0 +1,30 @@
/* Tailwind v4의 핵심! 이 한 줄로 모든 유틸리티 클래스를 불러옵니다. */
@import "tailwindcss";
/* 다크 모드 기반의 플레이어이므로 배경을 기본적으로 어둡게 세팅합니다. */
@theme {
--color-background: #171717; /* neutral-900 */
--color-foreground: #ffffff;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
margin: 0;
padding: 0;
}
/* 스크롤바 예쁘게 숨기기 (선택 사항) */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #52525b;
}

23
page/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
import Providers from "@/components/Providers"; // 🌟 방금 만든 Provider 불러오기
export const metadata: Metadata = {
title: "MusicBot Web",
description: "디스코드 음악 봇 웹 대시보드",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body>
{/* 🌟 앱 전체를 Providers로 감싸줍니다 */}
<Providers>{children}</Providers>
</body>
</html>
);
}

66
page/src/app/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import TopNav from "@/components/layout/TopNav";
import LeftSidebar from "@/components/layout/LeftSidebar";
import MainContent from "@/components/player/MainContent";
import QueueSidebar from "@/components/player/QueueSidebar";
import PlayerBar from "@/components/player/PlayerBar";
// 화면 모드 타입 정의
export type ViewMode = "SERVER_LIST" | "SERVER_DETAIL" | "SEARCH_RESULT";
export default function MusicPlayerLayout() {
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
const [selectedServer, setSelectedServer] = useState<any>(null);
const [searchQuery, setSearchQuery] = useState("");
// 홈 버튼 클릭 시: 서버 목록(또는 상세)으로 복귀
const handleHome = () => {
if (selectedServer) {
setViewMode("SERVER_DETAIL");
} else {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
}
};
// 검색 실행 시
const handleSearch = (query: string) => {
setSearchQuery(query);
setViewMode("SEARCH_RESULT");
};
// 서버 선택 시
const handleSelectServer = (server: any) => {
setSelectedServer(server);
setViewMode("SERVER_DETAIL");
};
return (
<div className="flex flex-col h-screen bg-neutral-900 text-white overflow-hidden font-sans">
<TopNav
onSearch={handleSearch}
onHome={handleHome}
selectedServer={selectedServer} // 🌟 이 줄을 추가해서 선택된 서버 정보를 넘깁니다.
/>
<div className="flex flex-1 overflow-hidden">
<LeftSidebar />
<MainContent
viewMode={viewMode}
setViewMode={setViewMode}
selectedServer={selectedServer}
setSelectedServer={setSelectedServer}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
onSelectServer={handleSelectServer}
/>
<QueueSidebar />
</div>
<PlayerBar />
</div>
);
}

View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -0,0 +1,25 @@
"use client";
import { ListMusic, Library } from "lucide-react";
export default function LeftSidebar() {
return (
<aside className="w-60 bg-black p-6 flex flex-col gap-6">
<nav className="flex flex-col gap-4 text-neutral-400 font-medium">
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
<Library size={20} />
</button>
<button className="flex items-center gap-3 hover:text-white transition-colors text-left">
<ListMusic size={20} />
</button>
</nav>
<hr className="border-neutral-800" />
<div className="flex flex-col gap-3 text-sm text-neutral-400 overflow-y-auto">
<p className="hover:text-white cursor-pointer truncate"> </p>
<p className="hover:text-white cursor-pointer truncate">2024 100</p>
<p className="hover:text-white cursor-pointer truncate"> </p>
</div>
</aside>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
import { Search, ListMusic, LogIn, LogOut, Home } from "lucide-react";
import { signIn, signOut, useSession } from "next-auth/react";
import { useState } from "react";
interface TopNavProps {
onSearch: (query: string) => void;
onHome: () => void;
selectedServer: any; // 🌟 추가: 선택된 서버 정보
}
export default function TopNav({ onSearch, onHome, selectedServer }: TopNavProps) {
const { data: session, status } = useSession();
const [term, setTerm] = useState("");
const handleSearchClick = () => {
// 🌟 서버 선택 여부 체크
if (!selectedServer) {
alert("먼저 왼쪽 목록에서 관리할 서버를 선택해주세요!");
return;
}
if (term.trim()) {
onSearch(term);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && term.trim()) {
onSearch(term);
}
};
// 🌟 홈 버튼 클릭 시 검색어 초기화 로직 추가
const handleHomeClick = () => {
setTerm(""); // 검색창 비우기
onHome(); // 원래 홈 기능 실행
};
return (
<nav className="h-16 flex-shrink-0 bg-black border-b border-neutral-800 flex items-center justify-between px-6 z-20">
{/* 1. 왼쪽: 로고 */}
<div className="flex items-center gap-2 text-xl font-bold text-white w-52">
<ListMusic className="text-green-500" />
<span>MusicBot</span>
</div>
{/* 2. 가운데: 검색창 & 홈 버튼 */}
<div className="flex-1 max-w-2xl px-4 flex items-center gap-4">
<button
onClick={handleHomeClick} // 🌟 수정된 핸들러 연결
className="p-2 hover:bg-neutral-800 rounded-full transition-colors cursor-pointer text-neutral-400 hover:text-white"
title="홈으로"
>
<Home size={22} />
</button>
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" size={18} />
<input
type="text"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={handleKeyDown}
disabled={!selectedServer} // 🌟 서버 미선택 시 입력창 비활성화 (선택 사항)
placeholder={selectedServer ? "노래 검색 후 엔터를 누르세요" : "서버를 먼저 선택해주세요"}
className={`w-full h-10 pl-10 pr-4 rounded-full bg-neutral-800 border border-neutral-700 text-sm text-white focus:outline-none focus:ring-2 focus:ring-white/30 transition-all ${!selectedServer ? 'opacity-50 cursor-not-allowed' : 'opacity-100'}`}
/>
</div>
</div>
{/* 3. 오른쪽: 유저 프로필 영역 (기존과 동일) */}
<div className="flex items-center justify-end w-auto min-w-[13rem]">
{status === "loading" ? (
<div className="text-sm text-neutral-400"> ...</div>
) : status === "authenticated" && session?.user ? (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 bg-neutral-800/80 pr-4 pl-1 py-1 rounded-full border border-neutral-700 shadow-sm cursor-default">
<img src={session.user.image || ""} alt="Profile" className="w-7 h-7 rounded-full" />
<span className="text-sm font-medium text-white whitespace-nowrap">{session.user.name}</span>
</div>
<button onClick={() => signOut()} className="cursor-pointer text-neutral-400 hover:text-red-400 p-2 rounded-full hover:bg-neutral-800 transition-colors">
<LogOut size={18} />
</button>
</div>
) : (
<button onClick={() => signIn("discord")} className="cursor-pointer flex items-center gap-2 bg-[#5865F2] hover:bg-[#4752C4] text-white px-5 py-2 rounded-full font-medium transition-colors text-sm shadow-md">
<LogIn size={16} /> Discord
</button>
)}
</div>
</nav>
);
}

View File

@@ -0,0 +1,212 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Play, ChevronLeft, Server, Music, Loader2, SearchX } from "lucide-react";
import { ViewMode } from "@/app/page";
interface MainContentProps {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
selectedServer: any;
setSelectedServer: (server: any) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
onSelectServer: (server: any) => void;
}
export default function MainContent({
viewMode,
setViewMode,
selectedServer,
setSelectedServer,
searchQuery,
setSearchQuery,
onSelectServer,
}: MainContentProps) {
const { data: session, status } = useSession();
const [servers, setServers] = useState<any[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [searchResults, setSearchResults] = useState<any[]>([]);
const [isSearching, setIsSearching] = useState(false);
// 권한 계산 함수
const getPermissionLabel = (server: any) => {
if (!server) return "알 수 없음";
if (server.owner) return "👑 서버 주인";
try {
const perms = BigInt(server.permissions);
if ((perms & BigInt(0x8)) === BigInt(0x8)) return "🛠️ 관리자";
if ((perms & BigInt(0x20)) === BigInt(0x20)) return "⚙️ 매니저";
return "👤 일반 멤버";
} catch {
return "👤 일반 멤버";
}
};
// 1. 서버 목록 불러오기
useEffect(() => {
if (status === "authenticated") {
setIsFetching(true);
const cached = sessionStorage.getItem("filtered_servers");
if (cached) {
setServers(JSON.parse(cached));
setIsFetching(false);
}
fetch("/api/servers")
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) {
setServers(data);
sessionStorage.setItem("filtered_servers", JSON.stringify(data));
}
})
.finally(() => setIsFetching(false));
}
}, [status]);
// 2. 검색 결과 불러오기
useEffect(() => {
if (viewMode === "SEARCH_RESULT" && searchQuery) {
setIsSearching(true);
setSearchResults([]);
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
.then(async (res) => {
const data = await res.json();
if (res.ok && Array.isArray(data)) {
setSearchResults(data);
} else {
console.error("Search Error:", data);
}
})
.catch(err => console.error("Fetch Error:", err))
.finally(() => setIsSearching(false));
}
}, [viewMode, searchQuery]);
// 로그인 안 된 상태
if (status === "unauthenticated") {
return (
<main className="flex-1 flex flex-col items-center justify-center bg-neutral-900">
<Server size={48} className="text-neutral-500 mb-4" />
<h2 className="text-2xl font-bold"> </h2>
<p className="text-neutral-400"> .</p>
</main>
);
}
// 세션 로딩 중
if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
return (
<main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
{/* 화면 1: 서버 목록 */}
{viewMode === "SERVER_LIST" && (
<>
<h2 className="text-2xl font-bold mb-6"> </h2>
{isFetching && servers.length === 0 ? (
<div className="flex flex-col items-center py-20">
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
<p> ...</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{servers.map((server) => (
<div
key={server.id}
onClick={() => onSelectServer(server)}
className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md"
>
<div className="aspect-square bg-neutral-700 rounded-full mb-4 overflow-hidden shadow-lg flex items-center justify-center">
{server.icon ? (
<img src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} className="w-full h-full object-cover" alt="" />
) : <Server size={32} className="text-neutral-500" />}
</div>
<h3 className="font-semibold text-center truncate">{server.name}</h3>
</div>
))}
</div>
)}
</>
)}
{/* 화면 2: 서버 상세 대시보드 */}
{viewMode === "SERVER_DETAIL" && selectedServer && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<button onClick={() => {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
}} className="flex items-center gap-1 text-neutral-400 hover:text-white mb-6 cursor-pointer group">
<ChevronLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
</button>
<div className="flex items-end gap-6 mb-8">
<div className="w-48 h-48 bg-neutral-800 rounded-2xl shadow-2xl flex items-center justify-center overflow-hidden">
{selectedServer.icon ? (
<img src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`} className="w-full h-full object-cover" alt="" />
) : <Server size={64} className="text-neutral-500" />}
</div>
<div>
<span className="text-sm font-bold uppercase text-neutral-400 tracking-wider"> </span>
<h1 className="text-5xl font-black mt-2 mb-4">{selectedServer.name}</h1>
<p className="text-neutral-400"> .</p>
</div>
</div>
<div className="bg-black/20 rounded-xl p-6 border border-neutral-800">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2"><Music size={20} /> </h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
<p className="text-neutral-500 mb-1"> ID</p>
<p className="font-mono text-white">{selectedServer.id}</p>
</div>
<div className="bg-neutral-800/50 p-4 rounded-lg border border-white/5">
<p className="text-neutral-500 mb-1"> </p>
<p className="text-green-400 font-bold">{getPermissionLabel(selectedServer)}</p>
</div>
</div>
</div>
</div>
)}
{/* 화면 3: 검색 결과 목록 */}
{viewMode === "SEARCH_RESULT" && (
<div>
<h2 className="text-2xl font-bold mb-2">"{searchQuery}" </h2>
<p className="text-neutral-400 mb-8 text-sm"> .</p>
{isSearching ? (
<div className="flex flex-col items-center py-20">
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
<p> ...</p>
</div>
) : searchResults.length === 0 ? (
<div className="flex flex-col items-center py-20 text-neutral-500">
<SearchX size={48} className="mb-4" />
<p> .</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{searchResults.map((track) => (
<div key={track.videoId} className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md">
<div className="aspect-square bg-neutral-700 rounded-md mb-4 relative overflow-hidden shadow-lg">
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
<div className="absolute right-2 bottom-2 bg-green-500 rounded-full p-3 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl">
<Play fill="black" stroke="black" size={20} />
</div>
</div>
<h3 className="font-semibold text-white truncate">{track.title}</h3>
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
</div>
))}
</div>
)}
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,47 @@
"use client";
import { SkipForward, SkipBack, Volume2, Pause } from "lucide-react";
export default function PlayerBar() {
return (
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
<div className="flex items-center gap-4 w-1/4">
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md"></div>
<div className="flex flex-col">
<span className="text-sm font-bold text-white hover:underline cursor-pointer"> </span>
<span className="text-xs text-neutral-400 hover:underline cursor-pointer">(IU)</span>
</div>
</div>
<div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
<div className="flex items-center gap-6">
<button className="text-neutral-400 hover:text-white transition-colors">
<SkipBack size={20} />
</button>
<button className="w-8 h-8 rounded-full bg-white flex items-center justify-center hover:scale-105 transition-transform text-black">
<Pause size={18} fill="black" />
</button>
<button className="text-neutral-400 hover:text-white transition-colors">
<SkipForward size={20} />
</button>
</div>
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
<span>01:12</span>
<div className="h-1 bg-neutral-600 rounded-full flex-1 cursor-pointer group">
<div className="h-full bg-white rounded-full w-1/3 group-hover:bg-green-500 transition-colors relative">
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md"></div>
</div>
</div>
<span>03:16</span>
</div>
</div>
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
<Volume2 size={20} />
<div className="w-24 h-1 bg-neutral-600 rounded-full cursor-pointer group">
<div className="h-full bg-white rounded-full w-2/3 group-hover:bg-green-500 transition-colors"></div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
export default function QueueSidebar() {
return (
<aside className="w-80 bg-black p-6 border-l border-neutral-800 flex flex-col">
<h2 className="text-lg font-bold mb-6"> </h2>
<div className="flex flex-col gap-4 overflow-y-auto">
{[1, 2, 3, 4, 5].map((item) => (
<div key={item} className="flex items-center gap-3 group cursor-pointer">
<div className="w-10 h-10 bg-neutral-800 rounded flex-shrink-0"></div>
<div className="flex flex-col overflow-hidden">
<span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
{item === 1 ? "Hype Boy" : "Supernova"}
</span>
<span className="text-xs text-neutral-400 truncate">
{item === 1 ? "NewJeans" : "aespa"}
</span>
</div>
</div>
))}
</div>
</aside>
);
}

38
page/src/lib/Logger.ts Normal file
View File

@@ -0,0 +1,38 @@
import colors from "colors/safe";
export const Timestamp = () => {
const Now = new Date();
Now.setHours(Now.getHours() + 9);
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
}
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
const log = (content: string, type: logType) => {
const timestamp = colors.white(`[${Timestamp()}]`);
switch (type) {
case "log":
return console.log(`${colors.gray("[LOG]")} ${timestamp} ${content}`);
case "info":
return console.log(`${colors.cyan("[INFO]")} ${timestamp} ${content}`);
case "warn":
return console.log(`${colors.yellow("[WARN]")} ${timestamp} ${content}`);
case "error":
return console.log(`${colors.red("[ERROR]")} ${timestamp} ${content}`);
case "debug":
return console.log(`${colors.magenta("[DEBUG]")} ${timestamp} ${content}`);
case "ready":
return console.log(`${colors.green("[READY]")} ${timestamp} ${content}`);
default:
throw new TypeError("Logger 타입이 올바르지 않습니다.");
}
};
export const Logger = {
log: (content: string) => log(content, "log"),
warn: (content: string) => log(content, "warn"),
error: (content: string) => log(content, "error"),
debug: (content: string) => log(content, "debug"),
info: (content: string) => log(content, "info"),
ready: (content: string) => log(content, "ready")
}

24
page/src/lib/Redis.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Redis as RedisClass } from "ioredis";
// .env.local 파일에서 설정한 IP를 가져옵니다. (기본값으로 Proxmox IP 세팅)
const REDIS_HOST = process.env.REDIS_HOST || "192.168.10.7";
// Next.js 개발 환경(HMR)에서 커넥션이 무한 증식하는 것을 막기 위한 글로벌 객체 선언
const globalForRedis = global as unknown as {
redisClient: RedisClass | undefined;
};
// 이미 연결된 객체가 있으면 그걸 쓰고, 없으면 새로 연결합니다.
export const Redis = globalForRedis.redisClient ?? new RedisClass({ host: REDIS_HOST, port: 6379 });
// 프로덕션(배포) 모드가 아닐 때만 글로벌 변수에 저장해 둡니다.
if (process.env.NODE_ENV !== "production") globalForRedis.redisClient = Redis;
// 연결 성공 시 로그 한 번만 찍기
Redis.on("connect", () => {
console.log("🟢 [Next.js] Proxmox Redis(우체국) 연결 완료!");
});
Redis.on("error", (err) => {
console.error("❌ [Next.js] Redis 연결 에러:", err);
});

34
page/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}