diff --git a/Front end/public/assets/api.js b/Front end/public/assets/api.js
new file mode 100644
index 0000000..209954b
--- /dev/null
+++ b/Front end/public/assets/api.js
@@ -0,0 +1,419 @@
+// public/assets/api.js
+// ─────────────────────────────────────────────────────────────────────────────
+// 백엔드 연결 + 로컬 더미 폴백 지원 IIFE 버전 (window.API 전역 객체)
+// ─────────────────────────────────────────────────────────────────────────────
+const API = (() => {
+ const BASE = 'http://localhost:3001';
+
+ const j = (r) => (r.ok ? r.json() : r.json().then((e) => { throw e; }));
+ const fx = (url, opt = {}) =>
+ fetch(url, { credentials: 'include', ...opt }).then(j);
+
+ // 감정 라벨 (백엔드/프론트 동일 키)
+ const MOODS = [
+ { key: 'happy', label: '기쁨' },
+ { key: 'sad', label: '슬픔' },
+ { key: 'calm', label: '차분' },
+ { key: 'angry', label: '분노' },
+ { key: 'energetic', label: '열정' },
+ ];
+
+ // ── 로컬 더미(폴백용) ──────────────────────────────────────────────────────
+ let DUMMY_TRACKS = [
+ {
+ id: 'youtube:gdZLi9oWNZg',
+ source: 'YouTube',
+ title: "BTS — Dynamite",
+ originalTitle: "BTS (방탄소년단) 'Dynamite' Official MV",
+ artist: 'BTS',
+ thumb: 'https://i.ytimg.com/vi/gdZLi9oWNZg/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=gdZLi9oWNZg',
+ moods: ['happy','energetic'],
+ genre: 'kpop',
+ nation: 'kr'
+ },
+ {
+ id: 'youtube:6eEZ7DJMzuk',
+ source: 'YouTube',
+ title: 'IVE — LOVE DIVE',
+ originalTitle: "IVE 아이브 'LOVE DIVE' MV",
+ artist: 'IVE',
+ thumb: 'https://i.ytimg.com/vi/6eEZ7DJMzuk/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=6eEZ7DJMzuk',
+ moods: ['happy','energetic'],
+ genre: 'kpop',
+ nation: 'kr'
+ },
+ {
+ id: 'youtube:TQ8WlA2GXbk',
+ source: 'YouTube',
+ title: 'Official髭男dism — Pretender',
+ originalTitle: 'Official髭男dism - Pretender [Official Video]',
+ artist: 'Official髭男dism',
+ thumb: 'https://i.ytimg.com/vi/TQ8WlA2GXbk/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=TQ8WlA2GXbk',
+ moods: ['sad','calm'],
+ genre: 'jpop',
+ nation: 'jp'
+ },
+ {
+ id: 'youtube:Q6iK6DjV_iE',
+ source: 'YouTube',
+ title: 'YOASOBI — 夜に駆ける',
+ originalTitle: 'YOASOBI「夜に駆ける」Official Music Video',
+ artist: 'YOASOBI',
+ thumb: 'https://i.ytimg.com/vi/Q6iK6DjV_iE/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=Q6iK6DjV_iE',
+ moods: ['energetic'],
+ genre: 'jpop',
+ nation: 'jp'
+ },
+ {
+ id: 'youtube:8xg3vE8Ie_E',
+ source: 'YouTube',
+ title: 'Taylor Swift — Love Story',
+ originalTitle: 'Taylor Swift - Love Story',
+ artist: 'Taylor Swift',
+ thumb: 'https://i.ytimg.com/vi/8xg3vE8Ie_E/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=8xg3vE8Ie_E',
+ moods: ['happy','calm'],
+ genre: 'pop',
+ nation: 'us'
+ },
+ {
+ id: 'youtube:RBumgq5yVrA',
+ source: 'YouTube',
+ title: 'Ed Sheeran — Photograph',
+ originalTitle: 'Ed Sheeran - Photograph (Official Music Video)',
+ artist: 'Ed Sheeran',
+ thumb: 'https://i.ytimg.com/vi/RBumgq5yVrA/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=RBumgq5yVrA',
+ moods: ['sad','calm'],
+ genre: 'pop',
+ nation: 'uk'
+ },
+ {
+ id: 'youtube:OPf0YbXqDm0',
+ source: 'YouTube',
+ title: 'Mark Ronson — Uptown Funk (feat. Bruno Mars)',
+ originalTitle: 'Mark Ronson - Uptown Funk (Official Video) ft. Bruno Mars',
+ artist: 'Mark Ronson',
+ thumb: 'https://i.ytimg.com/vi/OPf0YbXqDm0/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=OPf0YbXqDm0',
+ moods: ['energetic','happy'],
+ genre: 'pop',
+ nation: 'us'
+ },
+ {
+ id: 'youtube:d9h2oQxQv0c',
+ source: 'YouTube',
+ title: 'IU — Palette (feat. G-DRAGON)',
+ originalTitle: 'IU(아이유) _ Palette(팔레트) (Feat. G-DRAGON) MV',
+ artist: 'IU',
+ thumb: 'https://i.ytimg.com/vi/d9h2oQxQv0c/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=d9h2oQxQv0c',
+ moods: ['calm','happy'],
+ genre: 'kpop',
+ nation: 'kr'
+ },
+ {
+ id: 'youtube:pXRviuL6vMY',
+ source: 'YouTube',
+ title: 'twenty one pilots — Stressed Out',
+ originalTitle: 'twenty one pilots: Stressed Out [OFFICIAL VIDEO]',
+ artist: 'twenty one pilots',
+ thumb: 'https://i.ytimg.com/vi/pXRviuL6vMY/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=pXRviuL6vMY',
+ moods: ['angry','sad'],
+ genre: 'rock',
+ nation: 'us'
+ },
+ {
+ id: 'youtube:tAGnKpE4NCI',
+ source: 'YouTube',
+ title: 'Metallica — Nothing Else Matters',
+ originalTitle: 'Metallica: Nothing Else Matters (Official Music Video)',
+ artist: 'Metallica',
+ thumb: 'https://i.ytimg.com/vi/tAGnKpE4NCI/hqdefault.jpg',
+ url: 'https://www.youtube.com/watch?v=tAGnKpE4NCI',
+ moods: ['calm','sad'],
+ genre: 'rock',
+ nation: 'us'
+ }
+ ];
+
+ const cleanTitle = (s) =>
+ (s || '')
+ .replace(/\s*\[[^\]]*\]\s*/g, ' ')
+ .replace(/\s*\([^\)]*\)\s*/g, ' ')
+ .replace(/\s{2,}/g, ' ')
+ .trim();
+
+ function list() { return [...DUMMY_TRACKS]; }
+
+ function searchLocal(q) {
+ if (!q) return list();
+ const t = q.toLowerCase();
+ return DUMMY_TRACKS.filter(v =>
+ v.title.toLowerCase().includes(t) ||
+ (v.originalTitle || '').toLowerCase().includes(t) ||
+ v.artist.toLowerCase().includes(t)
+ );
+ }
+
+ // ── 백엔드 API (우선 사용) ────────────────────────────────────────────────
+ async function me() {
+ try { return await fx(`${BASE}/api/me`); }
+ catch { return { user: null }; }
+ }
+
+ async function login({ username, password }) {
+ return fx(`${BASE}/api/login`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ username, password })
+ });
+ }
+
+ async function logout() {
+ return fx(`${BASE}/api/logout`, { method: 'POST' });
+ }
+
+ // ★ pageToken 지원 검색
+ async function search({ q, mood, genre, nation, pageToken } = {}) {
+ try {
+ const u = new URL(`${BASE}/api/search`);
+ if (q) u.searchParams.set('q', q);
+ if (mood) u.searchParams.set('mood', mood);
+ if (genre) u.searchParams.set('genre', genre);
+ if (nation) u.searchParams.set('nation', nation);
+ if (pageToken) u.searchParams.set('pageToken', pageToken);
+
+ return await fx(u); // { items, nextPageToken }
+ } catch {
+ // 폴백: 로컬 더미 (페이지네이션 X)
+ const items = searchLocal(q);
+ const filtered = mood ? items.filter(t => t.moods?.includes(mood)) : items;
+ return { items: filtered, nextPageToken: null };
+ }
+ }
+
+ async function recs({ mood, genre, nation, sub, tone } = {}) {
+ try {
+ const u = new URL(`${BASE}/api/recs`);
+ if (mood) u.searchParams.set('mood', mood);
+ if (genre) u.searchParams.set('genre', genre);
+ if (nation) u.searchParams.set('nation', nation);
+ if (sub) u.searchParams.set('sub', sub);
+ if (tone) u.searchParams.set('tone', tone);
+ return await fx(u);
+ } catch {
+ // 폴백: 로컬 더미에서 mood 위주로 정렬
+ const score = (t) =>
+ (mood && t.moods?.includes(mood) ? 2 : 0) +
+ (/official|mv|audio/i.test(t.originalTitle || t.original_title || '') ? 0.5 : 0);
+ const items = [...DUMMY_TRACKS].sort((a, b) => score(b) - score(a)).slice(0, 20);
+ return { items };
+ }
+ }
+
+ async function rate(trackId, mood) {
+ try {
+ return await fx(`${BASE}/api/ratings`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ trackId, mood })
+ });
+ } catch {
+ Ratings.setMood(trackId, mood);
+ return { ok: true, fallback: true };
+ }
+ }
+
+ async function watch(trackId) {
+ try {
+ return await fx(`${BASE}/api/watch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ trackId })
+ });
+ } catch {
+ return { ok: true, fallback: true };
+ }
+ }
+
+
+ // ✅ 내 재생목록 목록 (likes / liked / items 포함)
+ async function playlists() {
+ try {
+ return await fx(`${BASE}/api/playlists`);
+ } catch {
+ return { items: [{ id: 0, title: 'Local Playlist', is_public: 0, items: Playlist.get(), likes: 0, liked: false }] };
+ }
+ }
+
+ // ✅ 재생목록 생성
+ async function createPlaylist({ title, isPublic, themeColor }) {
+ try {
+ return await fx(`${BASE}/api/playlists`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title,
+ isPublic,
+ // 색을 안 넘겼으면 기본색 사용
+ themeColor: themeColor || '#22d3ee'
+ })
+ });
+ } catch {
+ // 서버 죽었을 때라도 UI 안터지게 하는 기존 폴백 유지
+ return { ok: true, id: 0, fallback: true };
+ }
+ }
+
+
+ // ✅ 재생목록 삭제
+ async function deletePlaylist(id) {
+ try {
+ return await fx(`${BASE}/api/playlists/${id}`, {
+ method: 'DELETE'
+ });
+ } catch {
+ // 폴백: 로컬 재생목록 싹 비우는 정도만
+ Playlist.set([]);
+ return { ok: true, fallback: true };
+ }
+ }
+
+
+ // ✅ 재생목록에 곡 추가
+ async function addToPlaylist({ playlistId, trackId, position = 0 }) {
+ try {
+ return await fx(`${BASE}/api/playlist/items`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playlistId, trackId, position })
+ });
+ } catch {
+ const t = list().find(x => x.id === trackId);
+ if (t) Playlist.add(t);
+ return { ok: true, fallback: true };
+ }
+ }
+
+ // ✅ 재생목록에서 곡 제거
+ async function removeFromPlaylist({ playlistId, trackId }) {
+ try {
+ return await fx(`${BASE}/api/playlist/items`, {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ playlistId, trackId })
+ });
+ } catch {
+ Playlist.remove(trackId);
+ return { ok: true, fallback: true };
+ }
+ }
+
+ // ⭐ 특정 재생목록 상세 (player에서 사용 예정)
+ async function playlistDetail(id) {
+ try {
+ return await fx(`${BASE}/api/playlists/${id}`);
+ } catch {
+ // 폴백: 로컬 재생목록을 한 개짜리 가짜 playlist라고 생각
+ return {
+ id: 0,
+ title: 'Local Playlist',
+ is_public: 0,
+ likes: 0,
+ owner_name: 'local',
+ items: Playlist.get()
+ };
+ }
+ }
+
+ // ⭐ 재생목록 좋아요 토글
+ async function togglePlaylistLike(id) {
+ try {
+ return await fx(`${BASE}/api/playlists/${id}/like`, {
+ method: 'POST'
+ });
+ } catch {
+ // 폴백: 로컬에서는 좋아요 상태를 굳이 저장 안 해도 됨
+ return { ok: true, liked: true, likes: 0, fallback: true };
+ }
+ }
+
+ // ✅ 공개 재생목록 목록 (최근 / 인기)
+ // API.publicPlaylists({ sort: 'recent' | 'popular', limit })
+ async function publicPlaylists({ sort = 'recent', limit = 20 } = {}) {
+ try {
+ const u = new URL(`${BASE}/api/playlists/public`);
+ if (sort) u.searchParams.set('sort', sort);
+ if (limit) u.searchParams.set('limit', String(limit));
+ return await fx(u);
+ } catch {
+ return { items: [] };
+ }
+ }
+
+ // ── localStorage 기반 (폴백용) ────────────────────────────────────────────
+ const Auth = {
+ user() {
+ try { return JSON.parse(localStorage.getItem('auth.user') || 'null'); }
+ catch { return null; }
+ },
+ setUser(name) {
+ localStorage.setItem('auth.user', JSON.stringify({ name }));
+ },
+ playlistKey() {
+ const u = Auth.user(); return u ? `playlist:${u.name}` : null;
+ },
+ ratingKey() {
+ const u = Auth.user(); return u ? `ratings:${u.name}` : null;
+ },
+ };
+
+ const Playlist = {
+ get(){
+ const k = Auth.playlistKey(); if(!k) return [];
+ try{ return JSON.parse(localStorage.getItem(k)) || []; } catch { return []; }
+ },
+ set(list){
+ const k = Auth.playlistKey(); if(!k) return;
+ localStorage.setItem(k, JSON.stringify(list));
+ },
+ add(track){
+ const list = Playlist.get();
+ if(!list.find(t=>t.id===track.id)){ list.push(track); Playlist.set(list); }
+ },
+ remove(id){
+ const list = Playlist.get().filter(t=>t.id!==id); Playlist.set(list);
+ }
+ };
+
+ const Ratings = {
+ get(){
+ const k = Auth.ratingKey(); if(!k) return {};
+ try{ return JSON.parse(localStorage.getItem(k)) || {}; } catch { return {}; }
+ },
+ set(map){
+ const k = Auth.ratingKey(); if(!k) return;
+ localStorage.setItem(k, JSON.stringify(map));
+ },
+ setMood(trackId, mood){
+ const m = Ratings.get(); m[trackId] = mood; Ratings.set(m);
+ }
+ };
+
+ return {
+ MOODS, cleanTitle,
+ list, searchLocal, Playlist, Ratings,
+ me, login, logout,
+ search, recs, rate, watch,
+ playlists, createPlaylist, addToPlaylist, removeFromPlaylist, deletePlaylist,
+ playlistDetail, togglePlaylistLike,
+ publicPlaylists,
+ playlistById: playlistDetail,
+ };
+})();
diff --git a/Front end/public/assets/app.js b/Front end/public/assets/app.js
new file mode 100644
index 0000000..c827dcb
--- /dev/null
+++ b/Front end/public/assets/app.js
@@ -0,0 +1,102 @@
+// assets/app.js
+// 공통 UI + 로그인 상태 관리
+
+import { API } from './api.js';
+
+// 감정 목록은 API 쪽에서 그대로 사용
+export const MOODS = API.MOODS;
+
+// ───────────────────────────────────────
+// AuthUI : 로그인 상태 + 헤더 동기화
+// ───────────────────────────────────────
+export const AuthUI = {
+ user: null,
+
+ // 서버에 로그인 여부 물어보고 헤더 버튼/뱃지 업데이트
+ async sync() {
+ let name = null;
+ try {
+ const r = await API.me();
+ name = r?.user ?? null;
+ } catch {
+ name = null;
+ }
+ this.user = name;
+
+ const loginLink = document.getElementById('loginLink');
+ const userBadge = document.getElementById('userBadge');
+ const logoutBtn = document.getElementById('logoutBtn');
+
+ if (loginLink && userBadge && logoutBtn) {
+ if (name) {
+ // 로그인 상태
+ loginLink.classList.add('hidden');
+ userBadge.classList.remove('hidden');
+ logoutBtn.classList.remove('hidden');
+ userBadge.textContent = `@${name}`;
+ } else {
+ // 비로그인 상태
+ loginLink.classList.remove('hidden');
+ userBadge.classList.add('hidden');
+ logoutBtn.classList.add('hidden');
+ userBadge.textContent = '';
+ }
+ }
+
+ // 로그아웃 버튼 동작
+ if (logoutBtn && !logoutBtn._wired) {
+ logoutBtn._wired = true;
+ logoutBtn.addEventListener('click', async () => {
+ try {
+ await API.logout();
+ } catch {}
+ this.user = null;
+ await this.sync();
+ });
+ }
+
+ return !!name;
+ },
+
+ /**
+ * 로그인 필요한 기능에서만 사용하는 헬퍼
+ * - 로그인 안돼 있으면 "로그인 후 이용 가능한 기능입니다."만 띄우고 끝.
+ * - redirect 옵션을 true로 주면 login.html로 보내고 싶을 때만 사용.
+ */
+ async gate(fn, { redirect = false } = {}) {
+ const ok = await this.sync();
+ if (!ok) {
+ alert('로그인 후 이용 가능한 기능입니다.');
+ if (redirect) {
+ location.href = './login.html';
+ }
+ return;
+ }
+ return fn();
+ }
+};
+
+// ───────────────────────────────────────
+// 감정 칩 렌더링 (player / search 등에서 사용)
+// ───────────────────────────────────────
+export function renderMoodChips() {
+ const box = document.getElementById('moodChips');
+ if (!box) return;
+
+ const active = document.body.getAttribute('data-mood') || 'calm';
+ box.innerHTML = '';
+
+ MOODS.forEach((m) => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'chip' + (m.key === active ? ' active' : '');
+ b.textContent = m.label;
+ b.onclick = () => {
+ document.body.setAttribute('data-mood', m.key);
+ // 다른 코드에서 감정 바뀜을 감지할 수 있도록 커스텀 이벤트 발행
+ const ev = new CustomEvent('moodchange', { detail: { mood: m.key } });
+ box.dispatchEvent(ev);
+ };
+ box.appendChild(b);
+ });
+}
diff --git a/Front end/public/assets/auth.js b/Front end/public/assets/auth.js
new file mode 100644
index 0000000..234dfdf
--- /dev/null
+++ b/Front end/public/assets/auth.js
@@ -0,0 +1,101 @@
+// assets/auth.js
+(function () {
+ const STORAGE_KEY = 'ep_user';
+
+ function getUser() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch (e) {
+ console.warn('user parse error', e);
+ return null;
+ }
+ }
+
+ function saveUser(name) {
+ const user = {
+ name,
+ loggedIn: true,
+ createdAt: Date.now()
+ };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
+ return user;
+ }
+
+ function logout() {
+ localStorage.removeItem(STORAGE_KEY);
+ location.href = './home.html';
+ }
+
+ function isLoggedIn() {
+ const u = getUser();
+ return !!(u && u.loggedIn);
+ }
+
+ // 상단 네비의 로그인/로그아웃/배지 상태 갱신
+ function updateAuthUI() {
+ const user = getUser();
+ const loginLink = document.getElementById('loginLink');
+ const userBadge = document.getElementById('userBadge');
+ const logoutBtn = document.getElementById('logoutBtn');
+
+ if (!loginLink && !userBadge && !logoutBtn) return;
+
+ if (user && user.loggedIn) {
+ if (loginLink) loginLink.classList.add('hidden');
+ if (userBadge) {
+ userBadge.textContent = user.name + ' 님';
+ userBadge.classList.remove('hidden');
+ }
+ if (logoutBtn) logoutBtn.classList.remove('hidden');
+ } else {
+ if (loginLink) loginLink.classList.remove('hidden');
+ if (userBadge) userBadge.classList.add('hidden');
+ if (logoutBtn) logoutBtn.classList.add('hidden');
+ }
+
+ if (logoutBtn) {
+ logoutBtn.onclick = (e) => {
+ e.preventDefault();
+ logout();
+ };
+ }
+ }
+
+ // 로그인 안 되어 있으면 로그인 페이지로 튕기기 + redirect 지원
+ function requireLogin(redirectPage) {
+ if (isLoggedIn()) return;
+
+ let msg = '로그인 후 이용할 수 있는 메뉴입니다.';
+ if (redirectPage === 'playlist') {
+ msg = '내 재생목록은 로그인이 필요한 서비스입니다.\n로그인 페이지로 이동합니다.';
+ }
+
+ alert(msg);
+
+ const qs = redirectPage
+ ? '?redirect=' + encodeURIComponent(redirectPage)
+ : '';
+ location.href = './login.html' + qs;
+ }
+
+ document.addEventListener('DOMContentLoaded', () => {
+ updateAuthUI();
+
+ // 이 페이지가 보호 대상이면 body에 data-auth-required="playlist" 처럼 달기
+ const body = document.body;
+ if (body && body.dataset.authRequired) {
+ requireLogin(body.dataset.authRequired);
+ }
+ });
+
+ // 다른 스크립트에서 쓰기 위해 export
+ window.Auth = {
+ getUser,
+ saveUser,
+ isLoggedIn,
+ requireLogin,
+ logout,
+ updateAuthUI
+ };
+})();
diff --git a/Front end/public/assets/player_queue_playlist.js b/Front end/public/assets/player_queue_playlist.js
new file mode 100644
index 0000000..3794bec
--- /dev/null
+++ b/Front end/public/assets/player_queue_playlist.js
@@ -0,0 +1,135 @@
+// assets/player_queue_playlist.js
+import { API } from './api.js';
+
+(function () {
+ const url = new URL(location.href);
+ const QUEUE_MODE = url.searchParams.get('queue'); // URL 파라미터에서 'queue' 값 가져오기 ('playlist'일 수도 있음)
+ const PLAYLIST_ID = url.searchParams.get('plid'); // URL 파라미터에서 'plid' 가져오기 (재생목록 ID)
+ const START_INDEX = parseInt(url.searchParams.get('index') || '0', 10); // 시작할 곡 인덱스, 기본 0
+
+ if (QUEUE_MODE !== 'playlist' || !PLAYLIST_ID) {
+ // 플레이리스트 모드가 아니면 함수 종료 (기존 추천 로직 유지)
+ return;
+ }
+
+ // 전역 큐 상태 (플레이리스트 전용)
+ let playQueue = []; // 재생할 곡 목록 배열
+ let currentIndex = 0; // 현재 재생 중인 곡 인덱스
+
+ // 추천 UI 숨기기 (있으면)
+ const hideRecommendUI = () => {
+ const suggestBtn = document.querySelector('#moreRecsBtn'); // 추천 버튼 선택
+ if (suggestBtn) suggestBtn.style.display = 'none'; // 숨기기
+ };
+
+ // 사이드바 영역 선택
+ const sideWrap = () =>
+ document.querySelector('#recommendList') || // 추천 리스트 영역
+ document.querySelector('#rightList') || // 오른쪽 리스트 영역
+ document.querySelector('#sideList'); // 사이드 리스트 영역
+
+ // 현재 재생 곡 ID 가져오기
+ const currentTrackId = () => playQueue[currentIndex]?.track_id || null;
+
+ // 특정 인덱스 곡 재생
+ async function loadAndPlay(index) {
+ if (index < 0 || index >= playQueue.length) return; // 유효 범위 체크
+ currentIndex = index; // 현재 인덱스 업데이트
+
+ // localStorage에 현재 곡 ID 저장 (기존 코드 호환용)
+ localStorage.setItem('currentTrackId', currentTrackId());
+
+ // 프로젝트에 loadTrackById 함수가 있으면 호출
+ if (typeof window.loadTrackById === 'function') {
+ await window.loadTrackById(currentTrackId()); // 곡 로드 및 재생
+ }
+
+ renderSidebarQueue(); // 사이드바 큐 UI 갱신
+ highlightActive(); // 현재 재생 곡 강조 표시
+ }
+
+ // 다음 곡 재생
+ function nextTrack() {
+ if (currentIndex < playQueue.length - 1) loadAndPlay(currentIndex + 1);
+ }
+
+ // 이전 곡 재생
+ function prevTrack() {
+ if (currentIndex > 0) loadAndPlay(currentIndex - 1);
+ }
+
+ // 사이드바에 플레이리스트 렌더링
+ function renderSidebarQueue() {
+ const wrap = sideWrap(); // 사이드바 영역 선택
+ if (!wrap) return; // 영역 없으면 종료
+ hideRecommendUI(); // 추천 UI 숨김
+
+ wrap.innerHTML = ''; // 기존 내용 초기화
+ playQueue.forEach((t, i) => {
+ const row = document.createElement('div');
+ row.className = 'rec-row'; // 각 곡 행 스타일
+
+ // 곡 썸네일, 제목, 아티스트, 소스, 재생 버튼
+ row.innerHTML = `
+

+
+
+ `;
+
+ row.addEventListener('click', () => loadAndPlay(i)); // 클릭 시 해당 곡 재생
+ wrap.appendChild(row); // 사이드바에 추가
+ });
+ }
+
+ // 현재 재생 곡 강조 표시
+ function highlightActive() {
+ document.querySelectorAll('.rec-row').forEach((r, i) => {
+ r.classList.toggle('active', i === currentIndex); // 현재 곡이면 'active' 클래스 추가
+ });
+ }
+
+ // 초기화
+ async function init() {
+ try {
+ const pl = await API.playlistById(PLAYLIST_ID); // API로 재생목록 불러오기
+ playQueue = (pl.items || []).map(it => ({
+ track_id: it.track_id, // 곡 ID
+ title: it.title, // 곡 제목
+ artist: it.artist, // 아티스트
+ thumb: it.thumb, // 썸네일
+ source: it.source // 출처
+ }));
+
+ if (!playQueue.length) {
+ alert('이 재생목록에 곡이 없습니다.');
+ location.href = './playlist_view.html?id=' + encodeURIComponent(PLAYLIST_ID); // 곡 없으면 재생목록 페이지로 이동
+ return;
+ }
+
+ // next/prev 버튼 연결 (한 번만)
+ const nextBtn = document.querySelector('#nextBtn');
+ const prevBtn = document.querySelector('#prevBtn');
+ if (nextBtn && !nextBtn._wired) { nextBtn._wired = true; nextBtn.addEventListener('click', nextTrack); }
+ if (prevBtn && !prevBtn._wired) { prevBtn._wired = true; prevBtn.addEventListener('click', prevTrack); }
+
+ const idx = Number.isFinite(START_INDEX)
+ ? Math.min(Math.max(0, START_INDEX), playQueue.length - 1) // 범위 체크
+ : 0;
+
+ await loadAndPlay(idx); // 시작 곡 재생
+ } catch (e) {
+ console.warn('playlist queue load failed:', e);
+ alert('재생목록을 불러오지 못했습니다.'); // 오류 처리
+ }
+ }
+
+ // DOM 준비 후 init 실행
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/Front end/public/assets/quiz.js b/Front end/public/assets/quiz.js
new file mode 100644
index 0000000..5f85474
--- /dev/null
+++ b/Front end/public/assets/quiz.js
@@ -0,0 +1,345 @@
+// assets/quiz.js
+(function () {
+ const wizard = document.getElementById('wizard');
+ if (!wizard) return; // 이 페이지가 아닐 때 안전 종료
+
+ const $ = (s) => document.querySelector(s);
+ const steps = Array.from(document.querySelectorAll('.step'));
+ const dots = Array.from(document.querySelectorAll('[data-step-dot]'));
+
+ const trailBox = document.getElementById('emotionTrail');
+
+ // ────────────────────────── 이모지 트레일 헬퍼 ──────────────────────────
+ function addTrailIcon(icon) {
+ if (!trailBox || !icon) return;
+ const span = document.createElement('span');
+ span.className = 'trail-icon';
+ span.textContent = icon;
+ trailBox.appendChild(span);
+ }
+
+ function clearTrail() {
+ if (!trailBox) return;
+ trailBox.innerHTML = '';
+ }
+
+ // 선택 결과 저장
+ const state = {
+ emotion: null, // happy / sad / angry / calm / passion
+ mood: null, // API용: happy / sad / angry / calm / energetic
+ subEmotion: null, // 외로움, 설렘 등 텍스트
+ subKey: null,
+ tone: null, // boost, soothe ...
+ genre: null, // kpop / jpop / pop / rock
+ nation: null, // kr / jp / global
+ color: null // warm / cool / dark / light (-> 지금은 안 씀)
+ };
+
+ let currentStep = 1;
+
+ // 감정 → API의 mood 키로 매핑
+ const EMOTION_TO_MOOD = {
+ happy: 'happy',
+ sad: 'sad',
+ angry: 'angry',
+ calm: 'calm',
+ passion: 'energetic'
+ };
+
+ // 상위 감정별 하위 감정 버튼 목록 + 아이콘
+ const SUB_EMOTIONS = {
+ happy: [
+ { key: 'in_love', label: '사랑이 넘친다', icon: '💖' },
+ { key: 'travel', label: '여행 가고 싶다', icon: '✈️' },
+ { key: 'excited', label: '그냥 너무 신난다', icon: '🎉' }
+ ],
+ sad: [
+ { key: 'lonely', label: '외로움이 크다', icon: '😔' },
+ { key: 'missing', label: '누군가가 그립다', icon: '😢' },
+ { key: 'drained', label: '아무것도 하기 싫다', icon: '🥱' }
+ ],
+ angry: [
+ { key: 'unfair', label: '억울하고 답답하다', icon: '😤' },
+ { key: 'annoyed', label: '짜증이 계속 난다', icon: '😡' },
+ { key: 'rage', label: '스트레스를 풀고 싶다', icon: '💢' }
+ ],
+ calm: [
+ { key: 'rest', label: '조용히 쉬고 싶다', icon: '🛌' },
+ { key: 'organize', label: '차분하게 정리하고 싶다', icon: '🧹' },
+ { key: 'reflect', label: '앞으로를 생각해보고 싶다', icon: '🧠' }
+ ],
+ passion: [
+ { key: 'achieve', label: '뭐라도 해내고 싶다', icon: '🏃♂️' },
+ { key: 'explosion', label: '열정이 폭발한다', icon: '🔥' },
+ { key: 'selfdev', label: '자기계발 모드 ON', icon: '📚' }
+ ]
+ };
+
+ // 1단계 감정 아이콘
+ const EMOTION_ICONS = {
+ happy: '😊',
+ sad: '😢',
+ angry: '😡',
+ calm: '🌿',
+ passion: '🔥'
+ };
+
+ // 이모지 트레일 전체 매핑
+ const EMOJI_TRAIL_MAP = {
+ // 1단계: 감정
+ emotion: EMOTION_ICONS,
+
+ // 2단계: 세부 감정 (subKey → icon)
+ sub: (function () {
+ const map = {};
+ Object.values(SUB_EMOTIONS).forEach(list => {
+ list.forEach(({ key, icon }) => {
+ map[key] = icon;
+ });
+ });
+ return map;
+ })(),
+
+ // 3단계: 분위기 (tone)
+ tone: {
+ boost: '\u{1F3B6}', // 🎶 기분이 더 좋아지는
+ soothe: '\u{1F319}', // 🌙 마음을 달래주는
+ energy: '\u26A1', // ⚡ 힘이 나는
+ breeze: '\u2601', // ☁ 아무 생각 없이 듣는
+ focus: '\u{1F9D8}' // 🧘 집중하기 좋은
+ },
+
+ // 4단계: 장르 (genre)
+ genre: {
+ // 🇰🇷 = U+1F1F0 U+1F1F7
+ kpop: '\uD83C\uDDF0\uD83C\uDDF7',
+ // 🇯🇵 = U+1F1EF U+1F1F5
+ jpop: '\uD83C\uDDEF\uD83C\uDDF5',
+ pop: '\u{1F30D}', // 🌍 POP(글로벌)
+ rock: '\u{1F3B8}' // 🎸 락 / 메탈
+ }
+ };
+
+ // ────────────────────────── 스텝 전환 ──────────────────────────
+ function setStep(n) {
+ currentStep = n;
+
+ steps.forEach((el, idx) => {
+ el.classList.toggle('hidden', idx !== n - 1);
+ });
+
+ dots.forEach((dot) => {
+ dot.classList.toggle('active', Number(dot.dataset.stepDot) === n);
+ });
+
+ // 뒤로가기 버튼 노출 범위 (1~4단계에서만 보이게)
+ const backBtn = document.getElementById('backBtn');
+ if (backBtn) {
+ if (n > 1 && n <= 5) backBtn.classList.remove('hidden');
+ else backBtn.classList.add('hidden');
+ }
+ }
+
+ // ────────────────────────── STEP1: 기본 감정 ──────────────────────────
+ $('#step1').addEventListener('click', (e) => {
+ const btn = e.target.closest('button.choice');
+ if (!btn) return;
+
+ const emo = btn.dataset.emotion;
+ state.emotion = emo;
+ state.mood = EMOTION_TO_MOOD[emo] || 'calm';
+
+ // 기본 감정 이모티콘 트레일에 추가
+ addTrailIcon(EMOJI_TRAIL_MAP.emotion[emo]);
+
+ if (state.mood) {
+ document.body.setAttribute('data-mood', state.mood);
+ }
+
+ // STEP2 버튼 동적 생성
+ const list = SUB_EMOTIONS[emo] || [];
+ const box = $('#subEmotionContainer');
+ box.innerHTML = '';
+ list.forEach((item) => {
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'choice';
+ b.dataset.subEmotion = item.label;
+ b.dataset.subKey = item.key;
+ b.textContent = item.label;
+
+ const label = item.icon ? `${item.icon} ${item.label}` : item.label;
+ b.textContent = label;
+
+ box.appendChild(b);
+ });
+
+ $('#step2Hint').textContent =
+ emo === 'happy'
+ ? '좋은 일이 있었군요! 어떤 기쁨인지 골라보세요.'
+ : emo === 'sad'
+ ? '조금 힘든 하루였군요. 어떤 슬픔에 가까운가요?'
+ : emo === 'angry'
+ ? '화가 날 땐 음악으로 안전하게 풀어봐요.'
+ : emo === 'calm'
+ ? '차분한 하루에 어울리는 느낌을 골라보세요.'
+ : '불타는 열정을 음악으로 더 끌어올려볼까요?';
+
+ setStep(2);
+ });
+
+ // ────────────────────────── STEP2: 하위 감정 ──────────────────────────
+ $('#step2').addEventListener('click', (e) => {
+ const btn = e.target.closest('button.choice');
+ if (!btn) return;
+
+ state.subEmotion = btn.dataset.subEmotion || btn.textContent.trim();
+ state.subKey = btn.dataset.subKey || null;
+
+ // 세부 감정 이모티콘 트레일에 추가
+ if (state.subKey) {
+ addTrailIcon(EMOJI_TRAIL_MAP.sub[state.subKey]);
+ }
+
+ setStep(3);
+ });
+
+ // ────────────────────────── STEP3: 음악 분위기 ──────────────────────────
+ $('#step3').addEventListener('click', (e) => {
+ const btn = e.target.closest('button.choice');
+ if (!btn) return;
+
+ state.tone = btn.dataset.tone;
+
+ // 분위기(tone) 이모티콘 트레일에 추가
+ if (state.tone) {
+ addTrailIcon(EMOJI_TRAIL_MAP.tone[state.tone]);
+ }
+
+ setStep(4);
+ });
+
+ // ────────────────────────── STEP4: 장르 / 국가 ──────────────────────────
+ $('#step4').addEventListener('click', (e) => {
+ const btn = e.target.closest('button.choice');
+ if (!btn) return;
+
+ state.genre = btn.dataset.genre;
+ state.nation = btn.dataset.nation || 'global';
+
+ // 장르(genre) 이모티콘 트레일에 추가
+ if (state.genre) {
+ addTrailIcon(EMOJI_TRAIL_MAP.genre[state.genre]);
+ }
+
+ // 5단계(결과 화면)으로 이동
+ setStep(5);
+
+ // 결과 문구 업데이트 + 결과 섹션 표시
+ updateResultText();
+ const resultSec = document.getElementById('result');
+ if (resultSec) {
+ resultSec.classList.remove('hidden');
+ resultSec.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ });
+
+ // ────────────────────────── STEP5: 결과 문구 생성 ──────────────────────────
+ function updateResultText() {
+ const box = $('#resultText');
+ if (!box) return;
+
+ const moodLabelMap = {
+ happy: '기쁜',
+ sad: '조금은 슬픈',
+ angry: '화가 난',
+ calm: '차분한',
+ energetic: '열정적인'
+ };
+
+ const moodLabel = moodLabelMap[state.mood] || '지금';
+ const genreLabel =
+ state.genre === 'kpop'
+ ? 'K-POP'
+ : state.genre === 'jpop'
+ ? 'J-POP'
+ : state.genre === 'rock'
+ ? '락/메탈'
+ : 'POP';
+
+ const toneLabel =
+ state.tone === 'boost'
+ ? '기분을 더 끌어올려 줄'
+ : state.tone === 'soothe'
+ ? '마음을 달래 줄'
+ : state.tone === 'energy'
+ ? '에너지를 채워 줄'
+ : state.tone === 'breeze'
+ ? '생각 없이 흘려듣기 좋은'
+ : state.tone === 'focus'
+ ? '집중하기 좋은'
+ : '오늘의';
+
+ box.textContent =
+ `${moodLabel} 당신에게 어울리는 ` +
+ `${genreLabel} 스타일의 ${toneLabel} 곡들을 추천해 드릴게요.`;
+ }
+
+ // ────────────────────────── 플레이어로 이동 ──────────────────────────
+ $('#goPlayer').addEventListener('click', (e) => {
+ e.preventDefault();
+
+ const params = new URLSearchParams();
+ if (state.mood) params.set('mood', state.mood);
+ if (state.genre) params.set('genre', state.genre);
+ if (state.nation) params.set('nation', state.nation);
+ if (state.tone) params.set('tone', state.tone);
+
+ // 세부 감정 코드도 같이 전송
+ if (state.subKey) {
+ params.set('sub', state.subKey); // in_love / lonely ...
+ } else if (state.subEmotion) {
+ params.set('sub', state.subEmotion); // 코드가 없으면 한글 문구라도
+ }
+
+ const qs = params.toString();
+ const url = qs ? `./player.html?${qs}` : './player.html';
+ location.href = url;
+ });
+
+ // ────────────────────────── 처음부터 다시 ──────────────────────────
+ $('#restart').addEventListener('click', () => {
+ state.emotion = null;
+ state.mood = null;
+ state.subEmotion = null;
+ state.subKey = null;
+ state.tone = null;
+ state.genre = null;
+ state.nation = null;
+ state.color = null;
+
+ document.body.setAttribute('data-mood', 'calm');
+ clearTrail();
+
+ setStep(1);
+ });
+
+ // ────────────────────────── 뒤로 가기 버튼 ──────────────────────────
+ const backBtn = document.getElementById('backBtn');
+if (backBtn) {
+ backBtn.addEventListener('click', () => {
+ if (currentStep <= 1) return;
+
+ // ★ 방금 선택했던 스텝의 아이콘 하나 되감기
+ if (trailBox && trailBox.lastChild) {
+ trailBox.removeChild(trailBox.lastChild);
+ }
+
+ const prevStep = currentStep - 1;
+ setStep(prevStep);
+ });
+}
+
+ // 초기 상태
+ setStep(1);
+})();
diff --git a/Front end/public/assets/styles.css b/Front end/public/assets/styles.css
new file mode 100644
index 0000000..bfdf820
--- /dev/null
+++ b/Front end/public/assets/styles.css
@@ -0,0 +1,431 @@
+/* ==========================================================================
+ Emotion Playlist — Global Styles (FULL REPLACEMENT)
+ - Dark theme with mood-dependent variables
+ - Shared components: topbar, buttons, panels, grid, list, chips, modal
+ - Player/Search/Playlist pages compatible
+ - Multi-step quiz UI (home.html) included
+ ========================================================================== */
+
+/* ── Theme Variables ─────────────────────────────────────────────────────── */
+:root{
+ --bg:#0f172a;
+ --panel:#111827;
+ --text:#e5e7eb;
+ --muted:#94a3b8;
+ --accent:#22d3ee;
+ --card:#0b1220;
+ --ring:rgba(34,211,238,.5);
+}
+
+/* mood themes */
+body[data-mood="happy"]{
+ --bg:#0b1a10; --panel:#0f2316; --text:#f2fce2; --accent:#84cc16; --card:#0a1a10;
+}
+body[data-mood="sad"]{
+ --bg:#0a1224; --panel:#0e1a30; --text:#dbeafe; --accent:#60a5fa; --card:#081122;
+}
+body[data-mood="calm"]{
+ --bg:#0b1616; --panel:#0f1f20; --text:#e0fbfc; --accent:#2dd4bf; --card:#0a1415;
+}
+body[data-mood="angry"]{
+ --bg:#1a0b0b; --panel:#240f0f; --text:#fee2e2; --accent:#f87171; --card:#1a0a0a;
+}
+body[data-mood="energetic"]{
+ --bg:#0b0b1a; --panel:#11112a; --text:#ede9fe; --accent:#a78bfa; --card:#0b0b22;
+}
+
+/* ── Base / Reset ───────────────────────────────────────────────────────── */
+*{ box-sizing:border-box }
+html,body{ height:100% }
+body{
+ margin:0;
+ background-color:var(--bg);
+ color:var(--text);
+ font-family:
+ ui-sans-serif,
+ system-ui,
+ -apple-system,
+ BlinkMacSystemFont,
+ "Segoe UI",
+ Roboto,
+ Helvetica,
+ Arial,
+ "Apple SD Gothic Neo",
+ "Noto Sans KR",
+ "Segoe UI Emoji",
+ "Apple Color Emoji",
+ "Noto Color Emoji",
+ sans-serif;
+
+ /* ✨ 감정 바뀔 때 부드럽게 전환되는 부분 */
+ transition:
+ background-color 0.9s ease,
+ color 0.9s ease;
+}
+a{ color:inherit; text-decoration:none }
+img{ display:block; max-width:100% }
+
+/* ── Topbar / Header ────────────────────────────────────────────────────── */
+.topbar{
+ position:sticky; top:0; z-index:10;
+ display:flex; align-items:center; justify-content:space-between; gap:10px;
+ padding:12px 16px;
+ background:var(--panel);
+ border-bottom:1px solid rgba(255,255,255,.06);
+
+ transition: background-color 0.35s ease, border-color 0.35s ease, box-shadow 0.35s ease;
+}
+.brand{ font-weight:800; letter-spacing:.2px; }
+.nav{ display:flex; gap:8px; align-items:center; }
+
+/* ── Buttons / Badges ───────────────────────────────────────────────────── */
+.btn{
+ padding:8px 12px; border-radius:10px;
+ border:1px solid rgba(255,255,255,.08);
+ background:var(--card); color:var(--text);
+ cursor:pointer;
+ transition:
+ outline-color .15s ease,
+ transform .04s ease,
+ background-color .25s ease,
+ border-color .25s ease,
+ color .25s ease;
+}
+.btn:hover{ outline:2px solid var(--ring) }
+.btn:active{ transform:translateY(1px) }
+.btn.primary{ background:var(--accent); color:#00110c; font-weight:700; border:none }
+.badge{
+ font-size:11px; padding:2px 6px; border-radius:6px;
+ background:rgba(255,255,255,.1);
+}
+.hidden{ display:none !important }
+
+/* ── Layout / Panels ────────────────────────────────────────────────────── */
+.grid{
+ display:grid; grid-template-columns:2fr 1fr; gap:12px; padding:12px;
+}
+@media (max-width:980px){ .grid{ grid-template-columns:1fr } }
+
+.panel{
+ background:var(--panel);
+ border:1px solid rgba(255,255,255,.06);
+ border-radius:14px;
+ padding:12px;
+
+ transition: background-color 0.35s ease, border-color 0.35s ease;
+}
+
+.title{ font-weight:800 }
+.sub{ font-size:12px; color:var(--muted) }
+
+/* ── Search Bar ─────────────────────────────────────────────────────────── */
+.search{
+ display:flex; gap:8px; max-width:640px; margin:8px auto;
+}
+.search input{
+ flex:1; padding:10px 12px; border-radius:10px; outline:none;
+ background:var(--card); color:var(--text);
+ border:1px solid rgba(255,255,255,.08);
+}
+
+/* ── Player Area ────────────────────────────────────────────────────────── */
+.iframe-wrap{ aspect-ratio:16/9; background:#000; border-radius:10px; overflow:hidden }
+.controls{ display:flex; gap:8px; align-items:center; margin-top:8px }
+
+.meta{
+ display:grid; grid-template-columns:auto 1fr auto;
+ gap:10px; align-items:center; margin-top:8px;
+}
+.meta img{ width:52px; height:52px; border-radius:8px; object-fit:cover }
+
+/* ── Mood Chips ─────────────────────────────────────────────────────────── */
+.moods{ display:flex; gap:8px; flex-wrap:wrap }
+.chip{
+ padding:8px 10px; border-radius:999px; cursor:pointer;
+ background:var(--card);
+ border:1px solid rgba(255,255,255,.08);
+
+ transition:
+ background-color 0.25s ease,
+ color 0.25s ease,
+ border-color 0.25s ease,
+ box-shadow 0.25s ease;
+}
+.chip:hover{ outline:2px solid var(--ring) }
+.chip.active{ background:var(--accent); color:#00110c; font-weight:700; border:none }
+
+/* ── Lists (search results / recs / playlist items) ─────────────────────── */
+.list{
+ display:grid; gap:8px;
+ max-height:calc(100vh - 240px);
+ overflow:auto;
+}
+.item{
+ display:grid; grid-template-columns:90px 1fr auto;
+ gap:10px; align-items:center;
+ padding:8px; border-radius:10px; cursor:pointer;
+ background:var(--card); border:1px solid transparent;
+
+ transition: background-color 0.25s ease, border-color 0.25s ease;
+}
+.item:hover{ border-color:var(--accent) }
+.item img{ width:90px; height:50px; border-radius:8px; object-fit:cover }
+
+/* ── Modal ──────────────────────────────────────────────────────────────── */
+.modal{
+ position:fixed; inset:0; padding-top:10vh;
+ display:flex; align-items:flex-start; justify-content:center;
+ background:rgba(0,0,0,.45);
+}
+.modal > .box{
+ background:var(--panel);
+ border:1px solid rgba(255,255,255,.08);
+ border-radius:12px;
+ padding:14px; min-width:320px;
+}
+
+/* ==========================================================================
+ Multi-Step Quiz (공통 step 스타일)
+ ========================================================================== */
+.step{ margin-top:14px }
+.step h2{ margin:6px 0 10px; font-size:1.05rem }
+
+.choices.grid{
+ display:grid;
+ grid-template-columns:repeat(2, minmax(0,1fr));
+ gap:8px;
+}
+@media (min-width:640px){
+ .choices.grid{ grid-template-columns:repeat(3, minmax(0,1fr)) }
+}
+
+.choice{
+ appearance:none;
+ border:1px solid rgba(255,255,255,.08);
+ background:var(--card); color:var(--text);
+ border-radius:12px; padding:10px 12px;
+ text-align:left; cursor:pointer;
+}
+.choice:hover{ outline:2px solid var(--ring) }
+
+/* 감정 테스트용 단계 도트 애니메이션 */
+.dots .dot{
+ width:8px;
+ height:8px;
+ border-radius:999px;
+ background:rgba(255,255,255,.18);
+ opacity:0.5;
+ transform:scale(1);
+ transition:
+ background-color .30s ease,
+ transform .25s ease,
+ opacity .25s ease,
+ box-shadow .25s ease;
+}
+
+.dots .dot.active{
+ background:var(--accent);
+ opacity:1;
+ transform:scale(1.6); /* 선택된 점 살짝 크게 */
+ box-shadow:0 0 8px rgba(0,0,0,.7); /* 은은한 발광 느낌 */
+}
+
+.resultbox{
+ padding:12px; border:1px dashed rgba(255,255,255,.25);
+ border-radius:10px; background:rgba(255,255,255,.04);
+}
+
+/* ==========================================================================
+ Footer / helper
+ ========================================================================== */
+.foot{
+ text-align:center; padding:10px; color:var(--muted);
+ border-top:1px solid rgba(255,255,255,.06);
+ background:var(--panel);
+
+ transition: background-color 0.35s ease, border-color 0.35s ease, color 0.35s ease;
+}
+
+/* 감상 페이지: 이 곡 평가용 감정 버튼 */
+.rating-block .sub {
+ color: #cbd5ff;
+ font-size: 0.9rem;
+}
+
+.chip.rate-chip {
+ background: #4338ca; /* 보라색 계열로 눈에 띄게 */
+ color: #fff;
+}
+
+.chip.rate-chip:hover {
+ background: #4f46e5;
+}
+
+/* ==========================
+ home.html 전용 스타일
+ ========================== */
+
+/* home 전체 레이아웃: 세로를 꽉 채우고 가운데 배치 */
+.home-main{
+ min-height:calc(100vh - 120px); /* 상단 헤더 감안 */
+ display:flex;
+ flex-direction:column;
+ align-items:center;
+ justify-content:center;
+ padding:32px 16px 48px;
+}
+
+/* 상단 히어로 타이틀 (더 크게) */
+.hero-title{
+ text-align:center;
+ font-size:2.1rem; /* 기존 1.8 → 2.1 */
+ font-weight:800;
+ margin:24px 0 28px;
+}
+
+/* 감정 테스트 카드(wizard)를 크게 보이게 */
+#wizard{
+ width:100%;
+ max-width:900px;
+ background:var(--panel);
+ border-radius:20px;
+ padding:32px 40px 40px; /* 패널 기본 padding보다 넉넉하게 */
+ border:1px solid rgba(255,255,255,.12);
+ box-shadow:0 24px 60px rgba(15,23,42,.8);
+}
+
+/* STEP1: 감정 선택 화면을 가운데 크게 */
+#step1{
+ text-align:center;
+}
+
+#step1 .step-title{
+ font-size:1.4rem;
+ margin:10px 0 18px;
+}
+
+#step1 .choices{
+ display:flex;
+ flex-wrap:wrap;
+ justify-content:center;
+ gap:12px;
+}
+
+#step1 .choice.big{
+ min-width:140px;
+ padding:14px 22px;
+ font-size:1.05rem;
+}
+
+/* wizard 상단: 이전 버튼 + 점 */
+.wizard-header{
+ position: relative;
+ display:flex;
+ align-items:center;
+ justify-content:center; /* 가운데 기준으로 맞추기 */
+ margin-bottom:20px;
+}
+
+/* 점들은 정확히 가운데 정렬 */
+.wizard-header .dots{
+ display:flex;
+ gap:6px;
+ justify-content:center;
+}
+
+/* 왼쪽 위에 살짝 고정되는 이전 버튼 */
+.btn.ghost{
+ background:transparent;
+ border:none;
+ padding-left:0;
+ padding-right:8px;
+ font-size:0.9rem;
+}
+
+/* 버튼을 헤더의 왼쪽 중앙에 고정 */
+#wizard #backBtn.btn.ghost{
+ position:absolute;
+ left:0;
+ top:50%;
+ transform:translateY(-50%);
+}
+
+.btn.ghost:hover{
+ outline:0;
+ text-decoration:underline;
+}
+
+/* 모든 step 공통: 가운데 정렬 */
+#wizard .step{
+ text-align:center;
+}
+
+/* 각 스텝 제목 크게 (wizard 한정) */
+#wizard .step h2{
+ font-size:1.5rem; /* 기존 1.3 → 1.5 */
+ margin:12px 0 22px;
+}
+
+/* 기본 choices: 중앙 배치 (wizard 한정) */
+#wizard .choices{
+ display:flex;
+ flex-wrap:wrap;
+ justify-content:center;
+ gap:12px;
+}
+
+/* grid 타입도 플렉스처럼 가운데 */
+#wizard .choices.grid{
+ display:flex;
+ flex-wrap:wrap;
+ justify-content:center;
+}
+
+/* STEP1만 살짝 더 크게 (이미 위에서 정의했지만 한 번 더 명시) */
+#step1 .choice.big{
+ min-width:140px;
+ padding:14px 22px;
+ font-size:1.05rem;
+}
+
+/* STEP2 힌트 텍스트도 중앙 */
+#step2Hint{
+ text-align:center;
+ margin-top:6px;
+}
+
+/* wizard 안의 선택 버튼은 좀 더 크고 둥글게 */
+#wizard .choice{
+ appearance:none;
+ border:1px solid rgba(255,255,255,.08);
+ background:var(--card); color:var(--text);
+ border-radius:14px;
+ padding:14px 20px; /* 기본 choice보다 넉넉하게 */
+ font-size:1.05rem;
+ text-align:left;
+ cursor:pointer;
+}
+
+/* 감정/분위기/장르 이모티콘 트레일 (크게 + 공간 확보) */
+.emotion-trail{
+ margin-top:60px;
+ display:flex;
+ justify-content:center;
+ gap:16px;
+ font-size:82px; /* 기존 32 → 40 */
+ min-height:98px; /* 아직 선택 안 했을 때도 자리 확보 */
+}
+
+.emotion-trail .trail-icon{
+ filter:drop-shadow(0 0 6px rgba(0,0,0,.6));
+ transition: transform .15s ease, opacity .2s ease;
+}
+
+
+/* 전체 톤 전환을 더 부드럽게 만들기 */
+.panel, .choice, .chip, .item, .modal, .topbar{
+ transition:
+ background-color 0.9s ease,
+ border-color 0.9s ease,
+ color 0.9s ease;
+}
diff --git a/Front end/public/home.html b/Front end/public/home.html
new file mode 100644
index 0000000..b9b272d
--- /dev/null
+++ b/Front end/public/home.html
@@ -0,0 +1,120 @@
+
+
+
+
+
+ Emotion Playlist · 감정 테스트
+
+
+
+
+
+
+
+ 감정으로 음악을 추천받아봐요!
+
+
+
+
+
+
+
+
+ 지금 어떤 감정이세요?
+
+
+
+
+
+
+
+
+
+
+
+ 조금 더 구체적으로 말해볼까요?
+
+
+
+
+
+
+ 지금은 어떤 분위기의 음악이 좋으신가요?
+
+
+
+
+
+
+
+
+
+
+
+ 어떤 음악 장르가 가장 끌리나요?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Front end/public/login.html b/Front end/public/login.html
new file mode 100644
index 0000000..3f279eb
--- /dev/null
+++ b/Front end/public/login.html
@@ -0,0 +1,93 @@
+
+
+
+
+
+ Emotion Playlist · Login
+
+
+
+
+
+
+ 아이디로 로그인
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Front end/public/player.html b/Front end/public/player.html
new file mode 100644
index 0000000..9dcf639
--- /dev/null
+++ b/Front end/public/player.html
@@ -0,0 +1,543 @@
+
+
+
+
+
+ Emotion Playlist · Player
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 이 곡의 분위기를 골라주세요!
+ (내 추천에 반영돼요)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Front end/public/playlist.html b/Front end/public/playlist.html
new file mode 100644
index 0000000..0c927cc
--- /dev/null
+++ b/Front end/public/playlist.html
@@ -0,0 +1,552 @@
+
+
+
+
+
+
+
+ Emotion Playlist · Playlists
+
+
+
+
+
+
+
+
+ 📂 재생목록
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
공유 재생목록
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+