This commit is contained in:
tkrmagid-desktop
2026-04-11 14:22:48 +09:00
parent b329ccc03b
commit 4037afeb68
12 changed files with 200 additions and 86 deletions

View File

@@ -5,6 +5,7 @@ const nextConfig: NextConfig = {
allowedDevOrigins: [
"192.168.10.13",
"localhost",
"music.tkrmagid.kr"
]
};

View File

@@ -30,7 +30,6 @@ export async function POST(request: Request) {
// Redis 게시판 확인
const resultData = await Redis.get(resultKey);
console.log(resultData);
if (resultData) {
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료

View File

@@ -30,7 +30,6 @@ export async function POST(request: Request) {
// Redis 게시판 확인
const resultData = await Redis.get(resultKey);
console.log(resultData);
if (resultData) {
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료

View File

@@ -23,14 +23,13 @@ export async function GET(request: Request) {
}));
// 4. 결과가 올라올 때까지 기다리기 (Polling)
// 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다.
// 최대 10번(약 10초) 동안 1.0초 간격으로 확인합니다.
for (let i=0; i<10; i++) {
// 0.5초 대기
await new Promise(resolve => setTimeout(resolve, 500));
// 1.0초 대기
await new Promise(resolve => setTimeout(resolve, 1000));
// Redis 게시판 확인
const resultData = await Redis.get(resultKey);
console.log(resultData);
if (resultData) {
// 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료

View File

@@ -15,7 +15,7 @@ export async function GET() {
const userGuildsRes = await fetch("https://discord.com/api/users/@me/guilds", {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
const userGuilds = await userGuildsRes.json();
const userGuilds = await userGuildsRes.json() ?? [];
// 2. Redis에서 봇이 속한 서버 목록(화이트리스트) 가져오기
const botGuildsData = await Redis.get("bot-guilds");

View File

@@ -1,7 +1,16 @@
"use client";
import { SessionProvider } from "next-auth/react";
// 👇 방금 만든 토스트 프로바이더를 불러옵니다.
import { ToastProvider } from "@/components/ToastProvider";
export default function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
return (
<SessionProvider>
{/* 👇 세션 프로바이더 안쪽에 토스트 프로바이더를 감싸줍니다. */}
<ToastProvider>
{children}
</ToastProvider>
</SessionProvider>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import React, { createContext, useContext, useState, ReactNode } from "react";
// 1. 토스트 타입 정의
interface Toast {
message: string;
type: "success" | "error";
}
interface ToastContextType {
showToast: (message: string, type: "success" | "error") => void;
}
// 2. Context 생성
const ToastContext = createContext<ToastContextType | undefined>(undefined);
// 3. 🌟 토스트 UI를 포함한 Provider 컴포넌트
export function ToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState<Toast | null>(null);
const showToast = (message: string, type: "success" | "error") => {
setToast({ message, type });
setTimeout(() => {
setToast(null);
}, 3000);
};
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* 화면 최상단에 떠있는 실제 토스트 UI */}
{toast && (
<div
className={`fixed bottom-10 left-1/2 transform -translate-x-1/2 px-6 py-3 rounded-full shadow-2xl flex items-center gap-3 z-[9999] animate-in fade-in slide-in-from-bottom-5 duration-300 font-medium text-sm text-white ${
toast.type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`}
>
{toast.message}
</div>
)}
</ToastContext.Provider>
);
}
// 4. 컴포넌트에서 쉽게 꺼내 쓸 수 있는 훅
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast는 ToastProvider 안에서만 사용할 수 있습니다.");
}
return context;
};

View File

@@ -13,18 +13,6 @@ 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);

View File

@@ -1,9 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
// 🌟 [수정됨] 상단 아이콘에 쓸 ListPlus 추가
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, ListPlus } from "lucide-react";
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } from "lucide-react";
import { ViewMode } from "@/app/page";
// 🌟 [추가됨] 전역 토스트 훅 불러오기
import { useToast } from "@/components/ToastProvider";
interface MainContentProps {
viewMode: ViewMode;
@@ -15,6 +16,12 @@ interface MainContentProps {
onSelectServer: (server: any) => void;
}
interface SearchResultsType {
spotify: any[];
youtubeMusic: any[];
youtubeVideo: any[];
}
export default function MainContent({
viewMode,
setViewMode,
@@ -25,19 +32,27 @@ export default function MainContent({
onSelectServer,
}: MainContentProps) {
const { data: session, status } = useSession();
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
const { showToast } = useToast();
const [servers, setServers] = useState<any[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [searchResults, setSearchResults] = useState<any[]>([]);
const [searchResults, setSearchResults] = useState<SearchResultsType>({
spotify: [],
youtubeMusic: [],
youtubeVideo: []
});
const [isSearching, setIsSearching] = useState(false);
// 🌟 [추가됨] 봇에게 재생 및 대기열 추가 명령을 내리는 함수
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => {
if (!selectedServer) {
alert("명령을 내릴 디스코드 서버가 선택되지 않았습니다.");
// 🌟 alert -> showToast 교체
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
return;
}
// 🌟 [추가된 부분] next-auth 세션에서 유저 식별자 가져오기
const userId = (session?.user as any)?.id;
let endpoint = "";
@@ -60,17 +75,19 @@ export default function MainContent({
const data = await res.json();
if (res.ok && data.success) {
// alert(data.message || "요청이 성공했습니다.");
// 🌟 성공 시 초록색 알림
showToast("재생목록에 곡을 추가했습니다!", "success");
} else {
alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다.");
// 🌟 에러 시 빨간색 알림
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
}
} catch (error) {
console.error("Music Action Error:", error);
alert('서버와 통신할 수 없습니다.');
// 🌟 네트워크 에러 시 알림
showToast('서버와 통신할 수 없습니다.', "error");
}
};
// 권한 계산 함수
const getPermissionLabel = (server: any) => {
if (!server) return "알 수 없음";
if (server.owner) return "👑 서버 주인";
@@ -84,7 +101,6 @@ export default function MainContent({
}
};
// 1. 서버 목록 불러오기
useEffect(() => {
if (status === "authenticated") {
setIsFetching(true);
@@ -106,17 +122,20 @@ export default function MainContent({
}
}, [status]);
// 2. 검색 결과 불러오기
useEffect(() => {
if (viewMode === "SEARCH_RESULT" && searchQuery) {
setIsSearching(true);
setSearchResults([]);
setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] });
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
.then(async (res) => {
const data = await res.json();
if (res.ok && Array.isArray(data)) {
setSearchResults(data);
if (res.ok && data) {
setSearchResults({
spotify: data.spotify || [],
youtubeMusic: data.youtubeMusic || [],
youtubeVideo: data.youtubeVideo || []
});
} else {
console.error("Search Error:", data);
}
@@ -126,7 +145,6 @@ export default function MainContent({
}
}, [viewMode, searchQuery]);
// 로그인 안 된 상태
if (status === "unauthenticated") {
return (
<main className="flex-1 flex flex-col items-center justify-center bg-neutral-900">
@@ -137,9 +155,30 @@ export default function MainContent({
);
}
// 세션 로딩 중
if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
const renderTrackCard = (track: any) => (
<div key={track.videoId || track.id} className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md w-full">
<div className="aspect-square bg-neutral-700 rounded-md mb-2 relative overflow-hidden shadow-lg">
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
<button
onClick={(e) => {
e.stopPropagation();
handleMusicAction('player_play', track);
}}
className="absolute right-2 bottom-2 cursor-pointer bg-green-500 hover:bg-green-400 rounded-full p-2 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl"
title="바로 재생"
>
<Play fill="black" stroke="black" size={16} />
</button>
</div>
<h3 className="font-medium text-white text-sm truncate">{track.title}</h3>
<p className="text-xs text-neutral-400 truncate">{track.artist}</p>
</div>
);
return (
<main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
@@ -153,19 +192,21 @@ export default function MainContent({
<p> ...</p>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{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"
className="bg-neutral-800/40 p-3 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md w-full"
>
<div className="aspect-square bg-neutral-700 rounded-full mb-4 overflow-hidden shadow-lg flex items-center justify-center">
<div className="aspect-square bg-neutral-700 rounded-full mb-2 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" />}
) : (
<Server size={24} className="text-neutral-500" />
)}
</div>
<h3 className="font-semibold text-center truncate">{server.name}</h3>
<h3 className="font-medium text-center text-sm truncate">{server.name}</h3>
</div>
))}
</div>
@@ -214,60 +255,61 @@ export default function MainContent({
</div>
)}
{/* 화면 3: 검색 결과 목록 */}
{/* 화면 3: 검색 결과 목록 (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>
<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 ? (
) : !hasAnyResults ? (
<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 group border border-transparent hover:border-neutral-700 shadow-md">
<div className="flex flex-col gap-10 pb-10">
{/* 🌟 썸네일 이미지 영역 (여기 안에 버튼 2개가 떠다닙니다) */}
<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} />}
{/* 🌟 [주석으로 제거] 우측 상단: 재생목록 추가 버튼 (위에서 아래로 스르륵) */}
{/* <button
onClick={(e) => {
e.stopPropagation(); // 부모 div 클릭 이벤트 방지
handleMusicAction('queue_add', track);
}}
className="absolute right-2 top-2 cursor-pointer bg-black/60 hover:bg-black/80 rounded-full p-2 opacity-0 group-hover:opacity-100 transition-all -translate-y-2 group-hover:translate-y-0 shadow-xl text-white backdrop-blur-sm"
title="재생목록에 추가"
>
<ListPlus fill="black" size={25} />
</button> */}
{/* 🌟 [수정됨] 우측 하단: 바로 재생 버튼 (기존 코드 유지 및 onClick 연결) */}
<button
onClick={(e) => {
e.stopPropagation();
handleMusicAction('player_play', track);
}}
className="absolute right-2 bottom-2 cursor-pointer bg-green-500 hover:bg-green-400 rounded-full p-3 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl"
title="바로 재생"
>
<Play fill="black" stroke="black" size={20} />
</button>
{/* 카테고리 1: Spotify */}
{searchResults.spotify.length > 0 && (
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400">
<Disc size={24} /> Spotify
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.spotify.map(renderTrackCard)}
</div>
</section>
)}
{/* 카테고리 2: YouTube Music */}
{searchResults.youtubeMusic.length > 0 && (
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500">
<Music size={24} /> YouTube Music
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.youtubeMusic.map(renderTrackCard)}
</div>
</section>
)}
{/* 카테고리 3: YouTube Video */}
{searchResults.youtubeVideo.length > 0 && (
<section>
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white">
<MonitorPlay size={24} className="text-red-600" /> YouTube
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
{searchResults.youtubeVideo.map(renderTrackCard)}
</div>
</section>
)}
<h3 className="font-semibold text-white truncate">{track.title}</h3>
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
</div>
))}
</div>
)}
</div>

View File

@@ -2,6 +2,7 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Trash2, GripVertical, Music } from "lucide-react";
import { useSession } from "next-auth/react";
import { useToast } from "@/components/ToastProvider";
interface QueueSidebarProps {
selectedServer: any;
@@ -10,6 +11,9 @@ interface QueueSidebarProps {
export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
const { data: session, status } = useSession();
// 👇 [추가] 토스트 사용 준비 완료!
const { showToast } = useToast();
const [queue, setQueue] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -123,12 +127,12 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
});
const data = await res.json();
if (!res.ok || !data.success) {
alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다.");
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
fetchQueue(); // 실패 시 원래 큐로 복구
}
} catch (error) {
console.error("순서 동기화 실패:", error);
alert("순서 변경을 서버에 적용하지 못했습니다.");
showToast("순서 변경을 서버에 적용하지 못했습니다.", "error");
fetchQueue();
}
};
@@ -152,12 +156,12 @@ export default function QueueSidebar({ selectedServer }: QueueSidebarProps) {
});
const data = await res.json();
if (!res.ok || !data.success) {
alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다.");
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
fetchQueue();
}
} catch (error) {
console.error("삭제 실패:", error);
alert("노래를 삭제하지 못했습니다.");
showToast("노래를 삭제하지 못했습니다.", "error");
fetchQueue();
}
};