From d4e53a9f5220b39f5cf3e06e22bea52feceb5c9a Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 12:03:28 +0900 Subject: [PATCH 1/9] Delete assets directory --- assets/api.js | 57 -------------------- assets/app.js | 133 ---------------------------------------------- assets/auth.js | 28 ---------- assets/styles.css | 65 ---------------------- 4 files changed, 283 deletions(-) delete mode 100644 assets/api.js delete mode 100644 assets/app.js delete mode 100644 assets/auth.js delete mode 100644 assets/styles.css diff --git a/assets/api.js b/assets/api.js deleted file mode 100644 index 855f658..0000000 --- a/assets/api.js +++ /dev/null @@ -1,57 +0,0 @@ -const API = (() => { - const MOODS = [ - { key: 'happy', label: '기쁨' }, - { key: 'sad', label: '슬픔' }, - { key: 'calm', label: '차분' }, - { key: 'angry', label: '분노' }, - { key: 'energetic', label: '에너지' }, - ]; - - // 더미 데이터 (원하면 data/songs.json으로 분리 가능) - let DUMMY_TRACKS = [ - { id:'youtube:dQw4w9WgXcQ', source:'YouTube', title:'Rick Astley — Never Gonna Give You Up', originalTitle:'Rick Astley - Never Gonna Give You Up (Official Music Video)', artist:'Rick Astley', thumb:'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', url:'https://www.youtube.com/watch?v=dQw4w9WgXcQ', moods:['energetic','happy'] }, - { id:'youtube:ktvTqknDobU', source:'YouTube', title:'Queen — Bohemian Rhapsody', originalTitle:'Queen – Bohemian Rhapsody (Official Video Remastered)', artist:'Queen', thumb:'https://i.ytimg.com/vi/ktvTqknDobU/hqdefault.jpg', url:'https://www.youtube.com/watch?v=ktvTqknDobU', moods:['energetic','sad'] }, - { id:'youtube:Zi_XLOBDo_Y', source:'YouTube', title:'Adele — Someone Like You', originalTitle:'Adele - Someone Like You (Official Music Video)', artist:'Adele', thumb:'https://i.ytimg.com/vi/Zi_XLOBDo_Y/hqdefault.jpg', url:'https://www.youtube.com/watch?v=Zi_XLOBDo_Y', moods:['sad','calm'] }, - { id:'youtube:fLexgOxsZu0', source:'YouTube', title:'Pharrell Williams — Happy', originalTitle:'Pharrell Williams - Happy (Official Music Video)', artist:'Pharrell Williams', thumb:'https://i.ytimg.com/vi/fLexgOxsZu0/hqdefault.jpg', url:'https://www.youtube.com/watch?v=fLexgOxsZu0', moods:['happy','energetic'] }, - { id:'youtube:V1Pl8CzNzCw', source:'YouTube', title:'Billie Eilish — bad guy', originalTitle:'Billie Eilish - bad guy', artist:'Billie Eilish', thumb:'https://i.ytimg.com/vi/V1Pl8CzNzCw/hqdefault.jpg', url:'https://www.youtube.com/watch?v=V1Pl8CzNzCw', moods:['angry','energetic'] }, - { 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:['calm','sad'] }, - { 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'] }, - { id:'youtube:hLQl3WQQoQ0', source:'YouTube', title:'Adele — Someone Like You (Live at The BRIT Awards 2011)', originalTitle:'Adele - Someone Like You (BRIT Awards 2011) Performance', artist:'Adele', thumb:'https://i.ytimg.com/vi/hLQl3WQQoQ0/hqdefault.jpg', url:'https://www.youtube.com/watch?v=hLQl3WQQoQ0', moods:['sad'] }, - { 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'] }, - { id:'youtube:ktvTqknDobU-lyric', source:'YouTube', title:'Queen — Bohemian Rhapsody (Lyrics)', originalTitle:'Queen - Bohemian Rhapsody (Lyrics)', artist:'Queen', thumb:'https://i.ytimg.com/vi/ktvTqknDobU/hqdefault.jpg', url:'https://www.youtube.com/watch?v=ktvTqknDobU', moods:['calm'] } - ]; - - const cleanTitle = (s) => - s.replace(/\s*\[[^\]]*\]\s*/g, ' ') - .replace(/\s*\([^\)]*\)\s*/g, ' ') - .replace(/\s{2,}/g, ' ') - .trim(); - - 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) - ); - } - - function list(){ return [...DUMMY_TRACKS]; } - - // localStorage 기반 재생목록/라벨 - 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, list, searchLocal, cleanTitle, Playlist, Ratings }; -})(); diff --git a/assets/app.js b/assets/app.js deleted file mode 100644 index 9fce8f2..0000000 --- a/assets/app.js +++ /dev/null @@ -1,133 +0,0 @@ -let ytPlayer = null; // YouTube player -let currentTrack = null; - -// YouTube IFrame API 콜백 (index.html에서만 DOM이 있음) -window.onYouTubeIframeAPIReady = function(){ - const el = document.getElementById('player'); - if (!el) return; // 다른 페이지면 패스 - ytPlayer = new YT.Player('player', { - height: '100%', - width: '100%', - videoId: '', - playerVars: { autoplay: 0, rel: 0, modestbranding: 1 }, - events: { onReady: onPlayerReady } - }); -}; - -function onPlayerReady(){ - const savedId = localStorage.getItem('currentTrackId'); - const t = (savedId && API.list().find(x => x.id === savedId)) || API.list()[0]; - if (t) loadTrack(t); -} - -function loadTrack(track){ - currentTrack = track; - localStorage.setItem('currentTrackId', track.id); - const videoId = track.id.split(':')[1]; - if (ytPlayer && ytPlayer.loadVideoById) ytPlayer.loadVideoById(videoId); - - const nowThumb = document.getElementById('nowThumb'); if (nowThumb) nowThumb.src = track.thumb; - const nowTitle = document.getElementById('nowTitle'); if (nowTitle) nowTitle.textContent = API.cleanTitle(track.title); - const nowSub = document.getElementById('nowSub'); if (nowSub) nowSub.textContent = `${track.artist} · ${track.originalTitle}`; - const nowSrc = document.getElementById('nowSource');if (nowSrc) nowSrc.textContent = track.source; - const openOnYT = document.getElementById('openOnYT'); if (openOnYT) openOnYT.href = track.url; - - renderRateChips(); -} - -function renderRateChips(){ - const box = document.getElementById('rateMoods'); - if (!box) return; - box.innerHTML = ''; - API.MOODS.forEach(m => { - const el = document.createElement('button'); - el.className = 'chip'; - el.textContent = m.label; - el.onclick = () => { - if (!Auth.user) return alert('로그인 후 라벨을 저장할 수 있어요.'); - API.Ratings.setMood(currentTrack.id, m.key); - alert(`이 곡에 "${m.label}" 라벨을 저장했습니다.`); - setMood(m.key); - renderRecs(); - }; - box.appendChild(el); - }); -} - -function renderMoodChips(){ - const holder = document.getElementById('moodChips'); - if (!holder) return; - holder.innerHTML = ''; - const active = document.body.getAttribute('data-mood') || 'calm'; - API.MOODS.forEach(m => { - const b = document.createElement('button'); - b.className = 'chip' + (active === m.key ? ' active' : ''); - b.textContent = m.label; - b.onclick = () => { setMood(m.key); renderRecs(); }; - holder.appendChild(b); - }); -} - -function setMood(key){ - document.body.setAttribute('data-mood', key); - renderMoodChips(); -} - -function sortedTracks(){ - const activeMood = document.body.getAttribute('data-mood'); - const rates = API.Ratings.get(); - return [...API.list()].sort((a, b) => { - const score = (t) => { - let s = 0; - if (t.moods?.includes(activeMood)) s += 2; - if (rates[t.id] === activeMood) s += 3; - if (/official|mv|audio/i.test(t.originalTitle)) s += 0.5; - return s; - }; - return score(b) - score(a); - }); -} - -function itemEl(track, opts = {}){ - const el = document.createElement('div'); - el.className = 'item'; - el.innerHTML = ` - thumb -
-
${API.cleanTitle(track.title)}
-
${track.artist} · ${track.source}
-
- ${opts.controls ? '
' + opts.controls + '
' : ''} - `; - el.onclick = (e) => { - if (e.target && e.target.closest('button')) return; // 버튼 클릭은 제외 - loadTrack(track); - }; - return el; -} - -function renderRecs(){ - const holder = document.getElementById('recList'); - if (!holder) return; - holder.innerHTML = ''; - sortedTracks().forEach(t => holder.appendChild(itemEl(t))); -} - -// Controls & header sync -function wireControls(){ - const playBtn = document.getElementById('playBtn'); if (playBtn) playBtn.onclick = () => { try { ytPlayer.playVideo(); } catch {} }; - const pauseBtn = document.getElementById('pauseBtn'); if (pauseBtn) pauseBtn.onclick = () => { try { ytPlayer.pauseVideo(); } catch {} }; - const addBtn = document.getElementById('addToPlaylistBtn'); - if (addBtn) addBtn.onclick = () => { - if (!Auth.user) return alert('로그인 후 재생목록을 사용할 수 있어요.'); - if (currentTrack) API.Playlist.add(currentTrack); - alert('재생목록에 추가했습니다.'); - }; -} - -// 페이지 공통 초기화 -(function init(){ - renderMoodChips(); - renderRecs(); - wireControls(); -})(); diff --git a/assets/auth.js b/assets/auth.js deleted file mode 100644 index d3f9075..0000000 --- a/assets/auth.js +++ /dev/null @@ -1,28 +0,0 @@ -const Auth = { - get user() { - try { return JSON.parse(localStorage.getItem('demo_user')); } - catch { return null; } - }, - login(name) { localStorage.setItem('demo_user', JSON.stringify({ name })); }, - logout() { localStorage.removeItem('demo_user'); }, - - playlistKey() { const u = Auth.user; return u ? `pl_${u.name}` : null; }, - ratingKey() { const u = Auth.user; return u ? `rate_${u.name}` : null; }, - - syncHeader() { - const badge = document.getElementById('userBadge'); - const loginLink = document.getElementById('loginLink'); - const logoutBtn = document.getElementById('logoutBtn'); - if (!badge || !loginLink || !logoutBtn) return; - - const isLogged = !!Auth.user; - badge.classList.toggle('hidden', !isLogged); - logoutBtn.classList.toggle('hidden', !isLogged); - loginLink.classList.toggle('hidden', isLogged); - - if (isLogged) badge.textContent = `로그인: ${Auth.user.name}`; - logoutBtn.onclick = () => { Auth.logout(); location.reload(); }; - } -}; - -document.addEventListener('DOMContentLoaded', Auth.syncHeader); diff --git a/assets/styles.css b/assets/styles.css deleted file mode 100644 index f270a18..0000000 --- a/assets/styles.css +++ /dev/null @@ -1,65 +0,0 @@ -:root { - --bg: #0f172a; - --panel: #111827; - --text: #e5e7eb; - --muted: #94a3b8; - --accent: #22d3ee; - --card: #0b1220; - --ring: rgba(34, 211, 238, 0.5); -} - -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; } - -* { box-sizing: border-box; } -html, body { height: 100%; } -body { - margin: 0; - background: var(--bg); - color: var(--text); - font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; -} - -/* layout */ -.topbar { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 16px; background:var(--panel); border-bottom:1px solid rgba(255,255,255,.06); position:sticky; top:0; z-index:10; } -.brand { font-weight:700; letter-spacing:.2px; } -.nav { display:flex; gap:8px; align-items:center; } -.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; } - -/* components */ -.btn { padding:10px 14px; border-radius:10px; border:1px solid rgba(255,255,255,.08); background:var(--card); color:var(--text); cursor:pointer; } -.btn:hover { outline:2px solid var(--ring); } -.btn.primary { background:var(--accent); color:#00110c; font-weight:700; border:none; } -.badge { font-size:10px; padding:2px 6px; border-radius:6px; background:rgba(255,255,255,.08); } -.hidden { display:none !important; } - -/* player */ -.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; } -.title { font-weight:700; } -.sub { font-size:12px; color: var(--muted); } -.rate { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:10px; } -.moods { display:flex; gap:8px; flex-wrap:wrap; } -.chip { padding:8px 10px; border-radius:999px; background:var(--card); border:1px solid rgba(255,255,255,.08); cursor:pointer; font-size:14px; } -.chip.active { background:var(--accent); color:#00110c; font-weight:700; border:none; } -.rightHeader { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; } -.list { display:grid; gap:8px; max-height:calc(100vh - 220px); overflow:auto; padding-right:4px; } -.item { display:grid; grid-template-columns:90px 1fr auto; gap:10px; align-items:center; padding:8px; border-radius:10px; background:var(--card); border:1px solid transparent; cursor:pointer; } -.item:hover { border-color:var(--accent); } -.item img { width:90px; height:50px; border-radius:8px; object-fit:cover; } -.t { font-weight:700; } -.s { font-size:12px; color: var(--muted); } - -/* forms */ -.search { display:flex; gap:8px; max-width:640px; margin:0 auto; } -.search input { flex:1; padding:10px 12px; border-radius:10px; background:var(--card); color:var(--text); border:1px solid rgba(255,255,255,.08); outline:none; } -.foot { padding:8px 12px; font-size:12px; color: var(--muted); text-align:center; } -.authbox { max-width:420px; margin:12px auto; } -.loginform { display:flex; gap:8px; } From 58e3de0fc4870d1a1f9033d49043f0413402efe0 Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 12:03:43 +0900 Subject: [PATCH 2/9] Delete data directory --- data/songs.json | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 data/songs.json diff --git a/data/songs.json b/data/songs.json deleted file mode 100644 index cf6eaf6..0000000 --- a/data/songs.json +++ /dev/null @@ -1,12 +0,0 @@ -[ - { - "id": "youtube:dQw4w9WgXcQ", - "source": "YouTube", - "title": "Rick Astley — Never Gonna Give You Up", - "originalTitle": "Rick Astley - Never Gonna Give You Up (Official Music Video)", - "artist": "Rick Astley", - "thumb": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg", - "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - "moods": ["energetic", "happy"] - } -] From 22fbe0480cb7e2cf0f0f4cda0de9b7707bce4986 Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 12:04:30 +0900 Subject: [PATCH 3/9] Delete login.html --- login.html | 71 ------------------------------------------------------ 1 file changed, 71 deletions(-) delete mode 100644 login.html diff --git a/login.html b/login.html deleted file mode 100644 index a090685..0000000 --- a/login.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - Emotion Playlist · Playlist - - - -
-
📻 내 재생목록
- -
- -
-
-
- -
로그인 사용자별로 재생목록(localStorage) 관리
- - - - - - From ebd1c60d0916ede425902d91b6501dcafd961b73 Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 12:05:49 +0900 Subject: [PATCH 4/9] Delete index.html --- index.html | 63 ------------------------------------------------------ 1 file changed, 63 deletions(-) delete mode 100644 index.html diff --git a/index.html b/index.html deleted file mode 100644 index 43151b9..0000000 --- a/index.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - Emotion Playlist · Home - - - -
-
🎵 Emotion Playlist
- -
- -
- -
-
-
- - - - YouTube에서 열기 -
-
- 썸네일 -
-
곡을 선택하세요
-
아티스트 · 원본 제목
-
- -
-
- 이 곡의 감정 라벨: -
-
-
- - - -
- -
Demo: YouTube 임베드 + 더미 데이터. 추후 /api/search 연동 가능.
- - - - - - - - From 9f32cfd06b3b323a4ff017063048d5d5ae3174b9 Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 12:06:02 +0900 Subject: [PATCH 5/9] Delete playlist.html --- playlist.html | 71 --------------------------------------------------- 1 file changed, 71 deletions(-) delete mode 100644 playlist.html diff --git a/playlist.html b/playlist.html deleted file mode 100644 index a090685..0000000 --- a/playlist.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - Emotion Playlist · Playlist - - - -
-
📻 내 재생목록
- -
- -
-
-
- -
로그인 사용자별로 재생목록(localStorage) 관리
- - - - - - From cdd91dafd7fb03ad910a7e4116e20578ccefb937 Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 12:06:17 +0900 Subject: [PATCH 6/9] Delete search.html --- search.html | 86 ----------------------------------------------------- 1 file changed, 86 deletions(-) delete mode 100644 search.html diff --git a/search.html b/search.html deleted file mode 100644 index 598ab55..0000000 --- a/search.html +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - Emotion Playlist · Search - - - -
-
🔎 Search
- -
- -
- - -
-
-
- -
결과 클릭 시 곡을 선택하고 홈으로 이동합니다.
- - - - - - From cba989ebf75883d91087e3292c4c8ef452f2486a Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 14:04:21 +0900 Subject: [PATCH 7/9] Add files via upload --- public/assets/api.js | 415 ++++++++++++++++++++++++++ public/assets/app.js | 102 +++++++ public/assets/auth.js | 101 +++++++ public/assets/quiz.js | 345 +++++++++++++++++++++ public/assets/styles.css | 431 +++++++++++++++++++++++++++ public/home.html | 120 ++++++++ public/login.html | 93 ++++++ public/player.html | 503 +++++++++++++++++++++++++++++++ public/playlist.html | 489 ++++++++++++++++++++++++++++++ public/playlist_view.html | 611 ++++++++++++++++++++++++++++++++++++++ public/search.html | 479 ++++++++++++++++++++++++++++++ public/signup.html | 137 +++++++++ 12 files changed, 3826 insertions(+) create mode 100644 public/assets/api.js create mode 100644 public/assets/app.js create mode 100644 public/assets/auth.js create mode 100644 public/assets/quiz.js create mode 100644 public/assets/styles.css create mode 100644 public/home.html create mode 100644 public/login.html create mode 100644 public/player.html create mode 100644 public/playlist.html create mode 100644 public/playlist_view.html create mode 100644 public/search.html create mode 100644 public/signup.html diff --git a/public/assets/api.js b/public/assets/api.js new file mode 100644 index 0000000..eaec07e --- /dev/null +++ b/public/assets/api.js @@ -0,0 +1,415 @@ +// 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`); // 서버 API 호출 + } 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}`); // 서버 API 호출 + } catch { + // 서버 실패 시 로컬 가짜 재생목록 반환 + 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, + }; +})(); diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..c827dcb --- /dev/null +++ b/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/public/assets/auth.js b/public/assets/auth.js new file mode 100644 index 0000000..234dfdf --- /dev/null +++ b/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/public/assets/quiz.js b/public/assets/quiz.js new file mode 100644 index 0000000..5f85474 --- /dev/null +++ b/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/public/assets/styles.css b/public/assets/styles.css new file mode 100644 index 0000000..bfdf820 --- /dev/null +++ b/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/public/home.html b/public/home.html new file mode 100644 index 0000000..b9b272d --- /dev/null +++ b/public/home.html @@ -0,0 +1,120 @@ + + + + + + Emotion Playlist · 감정 테스트 + + + +
+
🧠 감정 테스트
+ +
+ +
+ +

감정으로 음악을 추천받아봐요!

+ + +
+ +
+ +
+ + + + + +
+
+ + +
+

지금 어떤 감정이세요?

+
+ + + + + +
+
+ + + + + + + + + + + + +
+
+
+ + + + + + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..3f279eb --- /dev/null +++ b/public/login.html @@ -0,0 +1,93 @@ + + + + + + Emotion Playlist · Login + + + +
+
🔐 로그인
+ +
+ +
+
아이디로 로그인
+ +
+ + + + + + +
+ 아직 계정이 없나요? + 회원가입 +
+
+
+ + + + + + + + + + diff --git a/public/player.html b/public/player.html new file mode 100644 index 0000000..d8a3fc6 --- /dev/null +++ b/public/player.html @@ -0,0 +1,503 @@ + + + + + + Emotion Playlist · Player + + + + +
+
🎧 감상
+ +
+ +
+ +
+
+
+
+ +
+ + + + YouTube에서 열기 +
+ +
+ thumb +
+
곡을 선택하세요
+
아티스트 · 원본 제목
+
+ +
+ + +
+
+ 이 곡의 분위기를 골라주세요! + (내 추천에 반영돼요) +
+
+
+
+ + + +
+ + + + + + + + + + + + diff --git a/public/playlist.html b/public/playlist.html new file mode 100644 index 0000000..0df5c3c --- /dev/null +++ b/public/playlist.html @@ -0,0 +1,489 @@ + + + + + + Emotion Playlist · Playlists + + + + + +
+
📂 재생목록
+ +
+ +
+ + + +
+
+
내 재생목록
+
+ + + +
+ 테마 색 + +
+ +
+ +
+ +
+ +
+
+ + +
+
+
공유 재생목록
+
+ + +
+
+ +
+ +
+
+
+ + + + + + diff --git a/public/playlist_view.html b/public/playlist_view.html new file mode 100644 index 0000000..f458fb9 --- /dev/null +++ b/public/playlist_view.html @@ -0,0 +1,611 @@ + + + + + + Emotion Playlist · Playlist + + + + + + + +
+
📂 재생목록
+ +
+ +
+
+ +
+ +
+
+
트랙 목록
+ +
+
+ +
+
+
+ + + + + + + + diff --git a/public/search.html b/public/search.html new file mode 100644 index 0000000..5d76dd0 --- /dev/null +++ b/public/search.html @@ -0,0 +1,479 @@ + + + + + + Emotion Playlist · Search + + + + + +
+
🔎 Search
+ +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
검색 결과를 클릭하면 감상 페이지로 이동합니다.
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/signup.html b/public/signup.html new file mode 100644 index 0000000..424f38e --- /dev/null +++ b/public/signup.html @@ -0,0 +1,137 @@ + + + + + + Emotion Playlist · Signup + + + +
+
📝 회원가입
+ +
+ +
+
새 계정 만들기
+ +
+ + + + + + + + + +
+
+ + + + + + + + From ce99a1ce20438fd42abc27c190370e87e5a6b3fe Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 20:49:35 +0900 Subject: [PATCH 8/9] Add files via upload --- Front end/public/assets/api.js | 419 ++++++++++ Front end/public/assets/app.js | 102 +++ Front end/public/assets/auth.js | 101 +++ .../public/assets/player_queue_playlist.js | 135 +++ Front end/public/assets/quiz.js | 345 ++++++++ Front end/public/assets/styles.css | 431 ++++++++++ Front end/public/home.html | 120 +++ Front end/public/login.html | 93 +++ Front end/public/player.html | 543 ++++++++++++ Front end/public/playlist.html | 552 ++++++++++++ Front end/public/playlist_view.html | 787 ++++++++++++++++++ Front end/public/search.html | 479 +++++++++++ Front end/public/signup.html | 137 +++ 13 files changed, 4244 insertions(+) create mode 100644 Front end/public/assets/api.js create mode 100644 Front end/public/assets/app.js create mode 100644 Front end/public/assets/auth.js create mode 100644 Front end/public/assets/player_queue_playlist.js create mode 100644 Front end/public/assets/quiz.js create mode 100644 Front end/public/assets/styles.css create mode 100644 Front end/public/home.html create mode 100644 Front end/public/login.html create mode 100644 Front end/public/player.html create mode 100644 Front end/public/playlist.html create mode 100644 Front end/public/playlist_view.html create mode 100644 Front end/public/search.html create mode 100644 Front end/public/signup.html 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 = ` +
thumb
+
+
${t.title || ''}
+
${t.artist || ''} · ${t.source || ''}
+
+ + `; + + 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 + + + + +
+
🎧 감상
+ +
+ +
+ +
+
+
+
+ +
+ + + + YouTube에서 열기 +
+ +
+ thumb +
+
곡을 선택하세요
+
아티스트 · 원본 제목
+
+ +
+ + +
+
+ 이 곡의 분위기를 골라주세요! + (내 추천에 반영돼요) +
+
+
+
+ + + +
+ + + + + + + + + + + + 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 + + + + + + + +
+
📂 재생목록
+ +
+ +
+ +
+ + + + + +
+
+
내 재생목록
+
+ + + +
+ 테마 색 + +
+ +
+ +
+ +
+ +
+
+ + +
+
+
공유 재생목록
+
+ + +
+
+ +
+ +
+
+ + + + + + + + + \ No newline at end of file diff --git a/Front end/public/search.html b/Front end/public/search.html new file mode 100644 index 0000000..5d76dd0 --- /dev/null +++ b/Front end/public/search.html @@ -0,0 +1,479 @@ + + + + + + Emotion Playlist · Search + + + + + +
+
🔎 Search
+ +
+ +
+ + +
+ +
+ + +
+ +
+
+ +
검색 결과를 클릭하면 감상 페이지로 이동합니다.
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/Front end/public/signup.html b/Front end/public/signup.html new file mode 100644 index 0000000..424f38e --- /dev/null +++ b/Front end/public/signup.html @@ -0,0 +1,137 @@ + + + + + + Emotion Playlist · Signup + + + +
+
📝 회원가입
+ +
+ +
+
새 계정 만들기
+ +
+ + + + + + + + + +
+
+ + + + + + + + From 6cfa4c2a0e380aa9d5efdbb7b8ba612f1cbfd3e6 Mon Sep 17 00:00:00 2001 From: sangkrim Date: Tue, 2 Dec 2025 20:50:02 +0900 Subject: [PATCH 9/9] Delete public directory --- public/assets/api.js | 415 -------------------------- public/assets/app.js | 102 ------- public/assets/auth.js | 101 ------- public/assets/quiz.js | 345 --------------------- public/assets/styles.css | 431 --------------------------- public/home.html | 120 -------- public/login.html | 93 ------ public/player.html | 503 ------------------------------- public/playlist.html | 489 ------------------------------ public/playlist_view.html | 611 -------------------------------------- public/search.html | 479 ------------------------------ public/signup.html | 137 --------- 12 files changed, 3826 deletions(-) delete mode 100644 public/assets/api.js delete mode 100644 public/assets/app.js delete mode 100644 public/assets/auth.js delete mode 100644 public/assets/quiz.js delete mode 100644 public/assets/styles.css delete mode 100644 public/home.html delete mode 100644 public/login.html delete mode 100644 public/player.html delete mode 100644 public/playlist.html delete mode 100644 public/playlist_view.html delete mode 100644 public/search.html delete mode 100644 public/signup.html diff --git a/public/assets/api.js b/public/assets/api.js deleted file mode 100644 index eaec07e..0000000 --- a/public/assets/api.js +++ /dev/null @@ -1,415 +0,0 @@ -// 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`); // 서버 API 호출 - } 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}`); // 서버 API 호출 - } catch { - // 서버 실패 시 로컬 가짜 재생목록 반환 - 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, - }; -})(); diff --git a/public/assets/app.js b/public/assets/app.js deleted file mode 100644 index c827dcb..0000000 --- a/public/assets/app.js +++ /dev/null @@ -1,102 +0,0 @@ -// 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/public/assets/auth.js b/public/assets/auth.js deleted file mode 100644 index 234dfdf..0000000 --- a/public/assets/auth.js +++ /dev/null @@ -1,101 +0,0 @@ -// 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/public/assets/quiz.js b/public/assets/quiz.js deleted file mode 100644 index 5f85474..0000000 --- a/public/assets/quiz.js +++ /dev/null @@ -1,345 +0,0 @@ -// 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/public/assets/styles.css b/public/assets/styles.css deleted file mode 100644 index bfdf820..0000000 --- a/public/assets/styles.css +++ /dev/null @@ -1,431 +0,0 @@ -/* ========================================================================== - 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/public/home.html b/public/home.html deleted file mode 100644 index b9b272d..0000000 --- a/public/home.html +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - Emotion Playlist · 감정 테스트 - - - -
-
🧠 감정 테스트
- -
- -
- -

감정으로 음악을 추천받아봐요!

- - -
- -
- -
- - - - - -
-
- - -
-

지금 어떤 감정이세요?

-
- - - - - -
-
- - - - - - - - - - - - -
-
-
- - - - - - - - diff --git a/public/login.html b/public/login.html deleted file mode 100644 index 3f279eb..0000000 --- a/public/login.html +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - Emotion Playlist · Login - - - -
-
🔐 로그인
- -
- -
-
아이디로 로그인
- -
- - - - - - -
- 아직 계정이 없나요? - 회원가입 -
-
-
- - - - - - - - - - diff --git a/public/player.html b/public/player.html deleted file mode 100644 index d8a3fc6..0000000 --- a/public/player.html +++ /dev/null @@ -1,503 +0,0 @@ - - - - - - Emotion Playlist · Player - - - - -
-
🎧 감상
- -
- -
- -
-
-
-
- -
- - - - YouTube에서 열기 -
- -
- thumb -
-
곡을 선택하세요
-
아티스트 · 원본 제목
-
- -
- - -
-
- 이 곡의 분위기를 골라주세요! - (내 추천에 반영돼요) -
-
-
-
- - - -
- - - - - - - - - - - - diff --git a/public/playlist.html b/public/playlist.html deleted file mode 100644 index 0df5c3c..0000000 --- a/public/playlist.html +++ /dev/null @@ -1,489 +0,0 @@ - - - - - - Emotion Playlist · Playlists - - - - - -
-
📂 재생목록
- -
- -
- - - -
-
-
내 재생목록
-
- - - -
- 테마 색 - -
- -
- -
- -
- -
-
- - -
-
-
공유 재생목록
-
- - -
-
- -
- -
-
-
- - - - - - diff --git a/public/playlist_view.html b/public/playlist_view.html deleted file mode 100644 index f458fb9..0000000 --- a/public/playlist_view.html +++ /dev/null @@ -1,611 +0,0 @@ - - - - - - Emotion Playlist · Playlist - - - - - - - -
-
📂 재생목록
- -
- -
-
- -
- -
-
-
트랙 목록
- -
-
- -
-
-
- - - - - - - - diff --git a/public/search.html b/public/search.html deleted file mode 100644 index 5d76dd0..0000000 --- a/public/search.html +++ /dev/null @@ -1,479 +0,0 @@ - - - - - - Emotion Playlist · Search - - - - - -
-
🔎 Search
- -
- -
- - -
- -
- - -
- -
-
- -
검색 결과를 클릭하면 감상 페이지로 이동합니다.
- - - - - - - - - - - - - \ No newline at end of file diff --git a/public/signup.html b/public/signup.html deleted file mode 100644 index 424f38e..0000000 --- a/public/signup.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - Emotion Playlist · Signup - - - -
-
📝 회원가입
- -
- -
-
새 계정 만들기
- -
- - - - - - - - - -
-
- - - - - - - -