feat(page): 대시보드 UI/UX 전면 개선 — 모바일 지원, 터치 조작, 접근성
실제 화면을 캡처해 확인한 문제: - 모바일(390px)에서 3열 고정 레이아웃(사이드바 240 + 대기열 320)이 본문을 ~50px 로 눌러 "로그인이 필요합니다"가 한 글자씩 세로로 표시되는 등 사실상 사용 불가였다. - 대기열의 삭제/드래그가 hover 에서만 노출돼, hover 가 없는 터치 기기에서는 접근 자체가 불가능. 순서 변경도 HTML5 드래그앤드롭뿐이라 모바일에서 동작하지 않았다. - 조작 요소가 터치 기준(44px)에 한참 못 미쳤다(재생 32px, 삭제 ~24px, 진행바 높이 1px). - 로그아웃 상태에서도 사이드바·대기열·플레이어바가 자리를 차지했고, 본문은 "로그인이 필요합니다" 라고만 적어둔 채 정작 버튼은 우상단에만 있어 동선이 끊겼다. - 아이콘 버튼에 접근성 이름이 없고(title 만), 검색창 안내가 로그인 전에도 "서버를 먼저 선택"이었다. 변경: - 레이아웃: 반응형으로 재구성. 좁은 화면은 단일 컬럼 + 대기열을 슬라이드 패널(FAB/플레이어바에서 토글), 넓은 화면은 기존 3열 유지. 로그인 전·서버 미선택 시에는 재생/대기열 영역을 아예 렌더링하지 않는다. - 대기열: 조작 버튼 상시 노출, 좁은 화면에는 위/아래 이동 버튼 제공(드래그 대체), 넓은 화면은 드래그 유지. 번호·곡수 표시 추가, 실패 시 이전 순서로 롤백. - 플레이어바: 버튼을 44~48px 로 키우고 모바일은 진행바를 상단 전체 폭으로 분리. 볼륨은 좁은 화면에서 숨김. - 검색 결과/서버 카드: 카드 전체를 버튼으로 만들어 hover 없이도 누를 수 있게 하고 재생 아이콘 상시 노출. - 로그인 화면에 실제 로그인 버튼 배치, 서버 미선택/결과 없음 등 빈 상태 문구 정리. - 좌측 사이드바를 안내문 전용 죽은 공간에서 "조작 중인 서버 + 서버 전환 + 사용법"으로 교체. - 아이콘 버튼 aria-label, range input aria-label, 장식 이미지 alt="" 정리. 프로필 이미지 없을 때 이니셜 대체. 검증: tsc 통과, lint 에러 0. 실제 렌더링을 데스크톱(1440x900)·모바일(390x844)에서 캡처해 로그인 전/서버 목록/서버 상세/검색 결과/대기열 패널 화면을 모두 확인했다.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc } from "lucide-react";
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc, ListMusic, LogIn } from "lucide-react";
|
||||
import { ViewMode } from "@/app/page";
|
||||
// 🌟 [추가됨] 전역 토스트 훅 불러오기
|
||||
import { useToast } from "@/components/ToastProvider";
|
||||
@@ -157,67 +157,108 @@ export default function MainContent({
|
||||
|
||||
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 className="flex flex-1 flex-col items-center justify-center gap-5 bg-neutral-900 px-6 text-center">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-neutral-800">
|
||||
<ListMusic size={32} className="text-green-500" aria-hidden />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">디스코드로 시작하기</h2>
|
||||
<p className="mt-2 max-w-sm text-sm leading-relaxed text-neutral-400">
|
||||
로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고,
|
||||
브라우저에서 바로 노래를 검색·재생할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
{/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */}
|
||||
<button
|
||||
onClick={() => signIn("discord")}
|
||||
className="flex h-12 items-center gap-2 rounded-full bg-[#5865F2] px-6 font-medium text-white shadow-lg transition hover:bg-[#4752C4] active:scale-95"
|
||||
>
|
||||
<LogIn size={18} aria-hidden />
|
||||
Discord 로그인
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "loading") return <main className="flex-1 bg-neutral-900" />;
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center bg-neutral-900">
|
||||
<Loader2 className="animate-spin text-neutral-600" size={32} aria-label="불러오는 중" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||
|
||||
// 카드 전체가 눌리는 버튼이다. 예전에는 hover 시에만 나타나는 작은 아이콘만 눌러야 해서
|
||||
// 터치 기기(hover 없음)에서는 재생 방법을 찾기 어려웠다.
|
||||
const renderTrackCard = (track: SearchTrack) => (
|
||||
<div key={track.videoId || track.id || track.url || track.title} 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>
|
||||
<button
|
||||
key={track.videoId || track.id || track.url || track.title}
|
||||
type="button"
|
||||
onClick={() => handleMusicAction('player_play', track)}
|
||||
aria-label={`${track.title} 재생목록에 추가`}
|
||||
className="group w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 text-left shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]"
|
||||
>
|
||||
<span className="relative mb-2 block aspect-square overflow-hidden rounded-md bg-neutral-700 shadow-lg">
|
||||
{track.thumbnail && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={track.thumbnail} className="h-full w-full object-cover" alt="" />
|
||||
)}
|
||||
{/* 터치 기기에서는 항상 보이고, 마우스 환경에서는 hover 시 강조된다. */}
|
||||
<span className="absolute bottom-2 right-2 flex h-11 w-11 items-center justify-center rounded-full bg-green-500 shadow-xl transition-all group-hover:bg-green-400 sm:h-10 sm:w-10 sm:opacity-90 sm:group-hover:opacity-100">
|
||||
<Play fill="black" stroke="black" size={18} aria-hidden />
|
||||
</span>
|
||||
</span>
|
||||
<span className="block truncate text-sm font-medium text-white">{track.title}</span>
|
||||
<span className="block truncate text-xs text-neutral-400">{track.artist}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 flex flex-col bg-gradient-to-b from-neutral-800/50 to-neutral-900 overflow-y-auto p-8">
|
||||
<main className="flex flex-1 flex-col overflow-y-auto bg-gradient-to-b from-neutral-800/50 to-neutral-900 p-4 pb-28 sm:p-6 lg:p-8">
|
||||
|
||||
{/* 화면 1: 서버 목록 */}
|
||||
{viewMode === "SERVER_LIST" && (
|
||||
<>
|
||||
<h2 className="text-2xl font-bold mb-6">접속 중인 서버 선택</h2>
|
||||
<h2 className="mb-1 text-xl font-bold sm:text-2xl">어느 서버에서 들을까요?</h2>
|
||||
<p className="mb-5 text-sm text-neutral-400">음악 봇이 들어가 있는 서버만 보입니다.</p>
|
||||
{isFetching && servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-20">
|
||||
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-neutral-400">
|
||||
<Loader2 className="animate-spin text-green-500" size={36} aria-hidden />
|
||||
<p>서버 목록을 불러오는 중...</p>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-20 text-center text-neutral-500">
|
||||
<Server size={40} className="opacity-50" aria-hidden />
|
||||
<p className="text-sm">표시할 서버가 없습니다.</p>
|
||||
<p className="max-w-sm text-xs text-neutral-600">
|
||||
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{servers.map((server) => (
|
||||
<div
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
onClick={() => onSelectServer(server)}
|
||||
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"
|
||||
className="w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]"
|
||||
>
|
||||
<div className="aspect-square bg-neutral-700 rounded-full mb-2 overflow-hidden shadow-lg flex items-center justify-center">
|
||||
<span className="mb-2 flex aspect-square items-center justify-center overflow-hidden rounded-full bg-neutral-700 shadow-lg">
|
||||
{server.icon ? (
|
||||
<img src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`} className="w-full h-full object-cover" alt="" />
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`}
|
||||
className="h-full w-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<Server size={24} className="text-neutral-500" />
|
||||
<Server size={24} className="text-neutral-500" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-center text-sm truncate">{server.name}</h3>
|
||||
</div>
|
||||
</span>
|
||||
<span className="block truncate text-center text-sm font-medium">{server.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -236,29 +277,45 @@ export default function MainContent({
|
||||
서버 목록으로 돌아가기
|
||||
</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">
|
||||
<div className="mb-6 flex flex-col items-center gap-4 text-center sm:flex-row sm:items-end sm:gap-6 sm:text-left">
|
||||
<span className="flex h-28 w-28 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800 shadow-2xl sm:h-40 sm:w-40">
|
||||
{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>
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`}
|
||||
className="h-full w-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
) : <Server size={56} className="text-neutral-500" aria-hidden />}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-neutral-400">서버 대시보드</span>
|
||||
<h1 className="mt-1 break-words text-2xl font-black sm:text-4xl lg:text-5xl">{selectedServer.name}</h1>
|
||||
<p className="mt-2 text-sm 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 className="mb-6 rounded-xl border border-green-500/20 bg-green-500/10 p-4">
|
||||
<p className="text-sm text-green-200">
|
||||
먼저 디스코드에서 <strong>음성 채널에 입장</strong>한 뒤, 노래를 검색해 재생 버튼을 누르세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-neutral-800 bg-black/20 p-4 sm:p-6">
|
||||
<h3 className="mb-4 flex items-center gap-2 text-base font-bold sm:text-lg">
|
||||
<Music size={20} aria-hidden /> 상세 정보
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 sm:gap-4">
|
||||
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
|
||||
<p className="mb-1 text-neutral-500">서버 고유 ID</p>
|
||||
<p className="break-all 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 className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
|
||||
<p className="mb-1 text-neutral-500">나의 서버 권한</p>
|
||||
<p className="font-bold text-green-400">{getPermissionLabel(selectedServer)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,8 +325,8 @@ export default function MainContent({
|
||||
{/* 화면 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>
|
||||
<h2 className="mb-1 break-words text-xl font-bold sm:text-2xl">“{searchQuery}” 검색 결과</h2>
|
||||
<p className="mb-6 text-sm text-neutral-400">곡을 누르면 대기열에 추가됩니다.</p>
|
||||
|
||||
{isSearching ? (
|
||||
<div className="flex flex-col items-center py-20">
|
||||
@@ -290,7 +347,7 @@ export default function MainContent({
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.spotify.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -302,7 +359,7 @@ export default function MainContent({
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.youtubeMusic.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -314,7 +371,7 @@ export default function MainContent({
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.youtubeVideo.map(renderTrackCard)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user