diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..2b837ef --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,4 @@ + diff --git a/.storybook/preview.ts b/.storybook/preview.ts new file mode 100644 index 0000000..1c183a6 --- /dev/null +++ b/.storybook/preview.ts @@ -0,0 +1,21 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import type { Preview } from '@storybook/react'; +import { initialize, mswLoader } from 'msw-storybook-addon'; + +// MSW 초기화 +initialize(); + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/ + } + } + }, + loaders: [mswLoader] +}; + +export default preview; diff --git a/package.json b/package.json index d41146d..a7c2c66 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "init": "^0.1.2", "jest": "^29.7.0", "moment": "^2.30.1", + "msw": "^2.6.0", "react": "^18.3.1", "react-calendar": "^5.1.0", "react-dom": "^18.3.1", "react-icons": "^5.3.0", "recharts": "^2.13.0", + "react-spinners": "^0.14.1", "storybook": "^8.3.0", "styled-components": "^6.1.13", "webpack-merge": "^6.0.1", @@ -57,6 +59,7 @@ "eslint-plugin-react-hooks": "^5.0.0", "html-webpack-plugin": "^5.6.0", "husky": "^8.0.0", + "msw-storybook-addon": "^2.0.3", "prettier": "^3.3.3", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", @@ -75,5 +78,10 @@ "format": "prettier --cache --write .", "lint": "eslint --cache .", "test": "jest" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/public/index.html b/public/index.html index 2de31c1..2d39747 100644 --- a/public/index.html +++ b/public/index.html @@ -9,6 +9,10 @@ type="text/css" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css" /> + Webpack without CRA diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..fc45489 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,295 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.6.0'; +const INTEGRITY_CHECKSUM = '07a8241b182f8a246a7cd39894799a9e'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window' + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE' + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM + } + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType + } + } + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries( + responseClone.headers.entries() + ) + } + }, + [responseClone.body] + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (activeClientIds.has(event.clientId)) { + return client; + } + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window' + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention']; + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive + } + }, + [requestBuffer] + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage( + message, + [channel.port2].concat(transferrables.filter(Boolean)) + ); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true + }); + + return mockedResponse; +} diff --git a/src/entities/music/api/fetchMusicList.ts b/src/entities/music/api/fetchMusicList.ts new file mode 100644 index 0000000..d29a8bd --- /dev/null +++ b/src/entities/music/api/fetchMusicList.ts @@ -0,0 +1,101 @@ +import { MusicItem, YouTubeResponse, SpotifyResponse } from '../model/type'; +import defaultApi from '../../../shared/api/api'; +import axios from 'axios'; +import React from 'react'; + +/** + * 스포티파이 토큰 발급 + * TODO - 토큰 캐싱 로직 추가 => useSearchMusic + * TODO - 토큰 만료 체크 로직 추가 + * @returns + */ +const getSpotifyToken = async () => { + const clientId = process.env.REACT_APP_SPOTIFY_CLIENT_ID; + const clientSecret = process.env.REACT_APP_SPOTIFY_CLIENT_SECRET; + + try { + const response = await axios.post( + 'https://accounts.spotify.com/api/token', + 'grant_type=client_credentials', + { + headers: { + Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`, + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + return response.data.access_token; + } catch (error) { + console.error('토큰 발급 에러 :', error); + throw error; + } +}; + +/** + * 스포티파이 api 인스턴스 + */ +const createSpotifyApi = async () => { + const token = await getSpotifyToken(); + return defaultApi({ + baseURL: 'https://api.spotify.com/v1', + headers: { + Authorization: `Bearer ${token}` + } + }); +}; + +/** + * 유튜브 api 인스턴스 + */ +const youtubeApi = defaultApi({ + baseURL: 'https://www.googleapis.com/youtube/v3', + params: { + key: process.env.REACT_APP_YOUTUBE_API_KEY + } +}); + +/** + * 음악 검색 함수 + * @param query 검색어 - gpt 응답 / 사용자 직접 입력 + * @returns + */ +export const searchMusic = async (query: string): Promise => { + try { + // 스포티파이 검색 + const spotifyApi = await createSpotifyApi(); + const spotifyResponse = await spotifyApi.get('/search', { + params: { + q: query, + type: 'track', + limit: 1 + } + }); + + const spotifyTrack = spotifyResponse.data.tracks + .items[0] as SpotifyResponse; + + // 유튜브 검색 + const youtubeResponse = await youtubeApi.get('/search', { + params: { + q: `${spotifyTrack.name} ${spotifyTrack.artists[0].name}`, + part: 'snippet', + type: 'video', + maxResults: 1 + } + }); + + const youtubeVideo = youtubeResponse.data.items[0] as YouTubeResponse; + + return { + title: spotifyTrack.name, + artist: spotifyTrack.artists[0].name, + thumbnailUrl: spotifyTrack.album.images[0].url, + // youtubeId: 'M5aEiDSx7kI' + // api 할당량 제한으로 그냥 아무 영상 id로 고정하고 테스트 중입니다. + youtubeId: youtubeVideo.id.videoId + }; + } catch (error) { + console.error('스포티파이 음악 검색 실패 :', error); + throw error; + } +}; diff --git a/src/entities/music/hooks/useSearchMusic.ts b/src/entities/music/hooks/useSearchMusic.ts new file mode 100644 index 0000000..8a604dc --- /dev/null +++ b/src/entities/music/hooks/useSearchMusic.ts @@ -0,0 +1,13 @@ +// import { useQuery } from '@tanstack/react-query'; +// import { searchMusic } from '../api/fetchMusicList'; + +// export const useSearchMusic = (query: string) => { +// return useQuery({ +// queryKey: ['music-search', query], +// queryFn: () => searchMusic(query), +// enabled: !!query, +// staleTime: 10 * 60 * 1000, +// gcTime: 30 * 60 * 1000, +// retry: 2 +// }); +// }; diff --git a/src/entities/music/index.ts b/src/entities/music/index.ts new file mode 100644 index 0000000..9d6d66a --- /dev/null +++ b/src/entities/music/index.ts @@ -0,0 +1,4 @@ +export { MusicCard } from './ui/MusicCard'; +export { EmptyMusicCard } from './ui/EmptyMusicCard'; +// export { useSearchMusic } from './hooks/useSearchMusic'; +export { searchMusic } from './api/fetchMusicList'; diff --git a/src/entities/music/model/type.ts b/src/entities/music/model/type.ts new file mode 100644 index 0000000..1a2e1e9 --- /dev/null +++ b/src/entities/music/model/type.ts @@ -0,0 +1,40 @@ +export interface MusicItem { + youtubeId: string; + thumbnailUrl: string; + title: string; + artist: string; +} + +export interface MusicCardListProps { + musicList: MusicItem[]; +} + +export interface MusicCardProps { + youtubeId: string; // 유튜브 ID + thumbnailUrl: string; // 스포티파이 썸네일 + title: string; // 곡 제목 + artist: string; // 곡 아티스트 + $isPlaying?: boolean; // 재생 상태 + onPlay?: (youtubeId: string) => void; // 재생 함수 + onClick: () => void; // 클릭시 아이템 셋팅 + $isSelected?: boolean; +} + +export interface MusicListQueryParams { + title: string; + artist: string; +} + +export interface SpotifyResponse { + name: string; + artists: Array<{ name: string }>; + album: { + images: Array<{ url: string }>; + }; +} + +export interface YouTubeResponse { + id: { + videoId: string; + }; +} diff --git a/src/entities/music/model/useMusicStore.ts b/src/entities/music/model/useMusicStore.ts new file mode 100644 index 0000000..52872f6 --- /dev/null +++ b/src/entities/music/model/useMusicStore.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; +import { MusicItem } from './type'; +import React from 'react'; + +interface MusicStore { + selectedMusic: MusicItem | null; + setSelectedMusic: (music: MusicItem | null) => void; + clearSelectedMusic: () => void; +} + +export const useMusicStore = create((set) => ({ + selectedMusic: null, + setSelectedMusic: (music) => set({ selectedMusic: music }), + clearSelectedMusic: () => set({ selectedMusic: null }) +})); + +export default useMusicStore; diff --git a/src/entities/music/ui/EmptyMusicCard.styled.tsx b/src/entities/music/ui/EmptyMusicCard.styled.tsx new file mode 100644 index 0000000..0e822af --- /dev/null +++ b/src/entities/music/ui/EmptyMusicCard.styled.tsx @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + width: 250px; + height: 300px; + display: flex; + flex-direction: column; + align-items: center; + border-radius: 10px; + padding: 1rem; + justify-content: center; +`; + +export const TextWrap = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; diff --git a/src/entities/music/ui/EmptyMusicCard.tsx b/src/entities/music/ui/EmptyMusicCard.tsx new file mode 100644 index 0000000..9276e5b --- /dev/null +++ b/src/entities/music/ui/EmptyMusicCard.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { Container, TextWrap } from './EmptyMusicCard.styled'; + +export const EmptyMusicCard = () => { + return ( + + 제목으로 노래를 검색해주세요! + + ); +}; diff --git a/src/entities/music/ui/MusicCard.stories.tsx b/src/entities/music/ui/MusicCard.stories.tsx new file mode 100644 index 0000000..3a76c23 --- /dev/null +++ b/src/entities/music/ui/MusicCard.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import {MusicCard} from './MusicCard'; +import React from 'react'; + +const meta: Meta = { + title: 'entities/music/MusicCard', + component: MusicCard, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +// 이미지 테스트 +const sampleImageUrl = "/samplecover.jpg"; + +export const Default: Story = { + args: { + thumbnailUrl: sampleImageUrl, + title: "Supernatural", + artist: "뉴진스", + $isPlaying: false, + onPlay: () => {}, + }, +}; + +export const Playing: Story = { + args: { + thumbnailUrl: sampleImageUrl, + title: "뉴진스", + artist: "Supernatural", + $isPlaying: true, + onPlay: () => {}, + }, +}; \ No newline at end of file diff --git a/src/entities/music/ui/MusicCard.styled.tsx b/src/entities/music/ui/MusicCard.styled.tsx new file mode 100644 index 0000000..7411d56 --- /dev/null +++ b/src/entities/music/ui/MusicCard.styled.tsx @@ -0,0 +1,155 @@ +import styled, { keyframes, css } from 'styled-components'; + +interface ContainerProps { + $isSelected: boolean; +} + +interface ThumbnailProps { + $isPlaying: boolean; +} + +const colorAnimation = keyframes` + 0% { + border-color: #FF480E; + } + 50% { + border-color: #ffc5b3; + } + 100% { + border-color: #FF480E; + } +`; + +export const Container = styled.div` + width: 250px; + /* height: 300px; */ + display: flex; + flex-direction: column; + align-items: center; + border-radius: 10px; + transition: all 0.2s ease; + border: ${(props) => (props.$isSelected ? '2px solid #a6a6a6' : 'none')}; + background-color: ${(props) => (props.$isSelected ? '#efefef' : 'none')}; + &:hover { + background-color: #f2f2f2; + cursor: pointer; + } + padding: 1rem; + justify-content: center; + gap: 0.5rem; +`; + +export const ThumbnailContainer = styled.div` + width: 250px; + height: 250px; + display: flex; + justify-content: center; + align-items: center; +`; + +export const IconWrapper = styled.div<{ $show: boolean }>` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 3; + opacity: ${(props) => (props.$show ? 1 : 0)}; + transition: opacity 0.2s ease; + background-color: rgba(255, 255, 255, 0.3); + border-radius: 50%; + padding: 10px; + display: flex; + justify-content: center; + align-items: center; + + .material-symbols-outlined { + font-size: 35px; + color: #ffffff; + } +`; + +export const Thumbnail = styled.div` + border-radius: 100%; + width: 220px; + height: 220px; + overflow: hidden; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + + ${IconWrapper} { + opacity: ${(props) => (props.$isPlaying ? 1 : 0)}; + } + + &:hover ${IconWrapper} { + opacity: 1; + } + + &::before { + content: ''; + position: absolute; + top: -10px; + left: -10px; + right: -10px; + bottom: -10px; + border-radius: 100%; + border: 15px solid transparent; + pointer-events: none; + transition: all 0.2s ease; + z-index: 2; + + ${(props) => + props.$isPlaying && + css` + animation: ${colorAnimation} 2s ease-in-out infinite; + `} + } + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: ${(props) => + props.$isPlaying ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0)'}; + transition: background-color 0.2s ease; + z-index: 1; + } + + &:hover { + cursor: pointer; + &::after { + background-color: ${(props) => + props.$isPlaying ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.2)'}; + } + } +`; + +export const StyledImg = styled.img` + object-fit: cover; + width: 220px; + height: 220px; + z-index: 0; +`; + +export const TextWrap = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + +export const Title = styled.div` + font-weight: bold; + font-size: 20px; + display: flex; + width: 100%; + text-align: center; +`; + +export const Artist = styled.div` + color: #939393; + font-size: 14px; +`; diff --git a/src/entities/music/ui/MusicCard.tsx b/src/entities/music/ui/MusicCard.tsx new file mode 100644 index 0000000..44befcb --- /dev/null +++ b/src/entities/music/ui/MusicCard.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { + Container, + ThumbnailContainer, + Thumbnail, + StyledImg, + TextWrap, + Title, + Artist, + IconWrapper +} from './MusicCard.styled'; +import { MusicCardProps } from '../model/type'; + +export const MusicCard = ({ + youtubeId, + thumbnailUrl, + title, + artist, + $isPlaying = false, + onPlay, + onClick, + $isSelected = false +}: MusicCardProps) => { + /** + * 이벤트 버블링을 막아서 썸네일을 클릭했을 때는 노래 재생만 처리합니다. + * @param e + */ + const handlePlay = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onPlay) { + onPlay(youtubeId); + } + }; + return ( + + + + + + {$isPlaying ? ( + + pause + + ) : ( + + play_arrow + + )} + + + + + {title} + {artist} + + + ); +}; diff --git a/src/features/diary-write/index.ts b/src/features/diary-write/index.ts index 7d8d809..8bc9c91 100644 --- a/src/features/diary-write/index.ts +++ b/src/features/diary-write/index.ts @@ -1,2 +1,4 @@ export { VisibilityButton } from './visibility/ui/VisibilityButton'; export { ConditionButtonGroup } from './condition/ui/ConditionButtonGroup'; +export { SearchModeButtonGroup } from './search-mode-selector/ui/SearchModeButtonGroup'; +export { MusicSearchInput } from './music-search-input/ui/MusicSearchInput'; diff --git a/src/features/diary-write/music-search-input/model/type.ts b/src/features/diary-write/music-search-input/model/type.ts new file mode 100644 index 0000000..70dd11f --- /dev/null +++ b/src/features/diary-write/music-search-input/model/type.ts @@ -0,0 +1,3 @@ +export interface MusicSearchInputProps { + onSearch: (keyword: string) => void; +} diff --git a/src/features/diary-write/music-search-input/ui/MusicSearchInput.styled.tsx b/src/features/diary-write/music-search-input/ui/MusicSearchInput.styled.tsx new file mode 100644 index 0000000..f7eb6af --- /dev/null +++ b/src/features/diary-write/music-search-input/ui/MusicSearchInput.styled.tsx @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +`; + +export const SearchFormWrapper = styled.div` + width: 100%; + display: flex; + align-items: flex-end; + gap: 0.5rem; +`; diff --git a/src/features/diary-write/music-search-input/ui/MusicSearchInput.tsx b/src/features/diary-write/music-search-input/ui/MusicSearchInput.tsx new file mode 100644 index 0000000..709ae5c --- /dev/null +++ b/src/features/diary-write/music-search-input/ui/MusicSearchInput.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import InputForm from '@/shared/ui/InputForm/InputForm'; +import { Container, SearchFormWrapper } from './MusicSearchInput.styled'; +import { useState } from 'react'; +import { MusicSearchInputProps } from '../model/type'; +import Button from '@/shared/ui/Button/Button'; + +export const MusicSearchInput = ({ onSearch }: MusicSearchInputProps) => { + const [searchKeyword, setSearchKeyword] = useState(''); + + // TODO - 디바운싱 + const handleContentChange = (value: string) => { + setSearchKeyword(value); + }; + + const handleSearchClick = () => { + onSearch(searchKeyword); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && searchKeyword.trim()) { + onSearch(searchKeyword); + } + }; + + return ( + + + + + + + ); +}; diff --git a/src/features/diary-write/musicList/model/type.ts b/src/features/diary-write/musicList/model/type.ts new file mode 100644 index 0000000..268c74a --- /dev/null +++ b/src/features/diary-write/musicList/model/type.ts @@ -0,0 +1,12 @@ +export interface MusicItem { + youtubeId: string; + thumbnailUrl: string; + title: string; + artist: string; +} + +export interface MusicCardListProps { + type: string; // 리스트 타입 + responseMusicList: MusicItem[]; + // onChange: (music: MusicItem) => void; +} diff --git a/src/features/diary-write/musicList/ui/MusicCardList.stories.tsx b/src/features/diary-write/musicList/ui/MusicCardList.stories.tsx new file mode 100644 index 0000000..63189d7 --- /dev/null +++ b/src/features/diary-write/musicList/ui/MusicCardList.stories.tsx @@ -0,0 +1,53 @@ +// MusicCardList.stories.tsx +import type { Meta, StoryObj } from '@storybook/react'; +import { MusicCardList } from './MusicCardList'; +import { SEARCH_TYPE } from '@/features/diary-write/search-mode-selector/model/type'; +import { MusicItem } from '@/entities/music/model/type'; + +const meta = { + title: 'Features/DiaryWrite/MusicCardList', + component: MusicCardList, + tags: ['autodocs'] +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sampleMusicList: MusicItem[] = [ + { + title: "Sample Music 1", + thumbnailUrl: "thumbnail_url_1", + artist: "Artist 1", + youtubeId: "video_id_1" + }, + { + title: "Sample Music 2", + thumbnailUrl: "thumbnail_url_2", + artist: "Artist 2", + youtubeId: "video_id_2" + } +]; + +export const GPTRecommend: Story = { + args: { + type: SEARCH_TYPE.GPT, + responseMusicList: sampleMusicList, + // onChange: (item: MusicItem) => console.log('Selected:', item) + } +}; + +export const UserSearch: Story = { + args: { + type: SEARCH_TYPE.USER, + responseMusicList: sampleMusicList, + // onChange: (item: MusicItem) => console.log('Selected:', item) + } +}; + +export const Empty: Story = { + args: { + type: SEARCH_TYPE.USER, + responseMusicList: [], + // onChange: (item: MusicItem) => console.log('Selected:', item) + } +}; \ No newline at end of file diff --git a/src/features/diary-write/musicList/ui/MusicCardList.styled.tsx b/src/features/diary-write/musicList/ui/MusicCardList.styled.tsx new file mode 100644 index 0000000..40d5d1d --- /dev/null +++ b/src/features/diary-write/musicList/ui/MusicCardList.styled.tsx @@ -0,0 +1,21 @@ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 35px; + width: 100%; + /* padding: 2rem; */ +`; + +export const HiddenYoutubeContainer = styled.div` + position: fixed; + top: -9999px; + left: -9999px; + width: 1px; + height: 1px; + visibility: hidden; +`; diff --git a/src/features/diary-write/musicList/ui/MusicCardList.tsx b/src/features/diary-write/musicList/ui/MusicCardList.tsx new file mode 100644 index 0000000..94426a9 --- /dev/null +++ b/src/features/diary-write/musicList/ui/MusicCardList.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; +import { Container, HiddenYoutubeContainer } from './MusicCardList.styled'; +import { MusicItem, MusicCardListProps } from '../model/type'; +import { EmptyMusicCard, MusicCard } from '../../../../entities/music'; +import useMusicStore from '@/entities/music/model/useMusicStore'; + +export const MusicCardList = ({ + responseMusicList + // onChange +}: MusicCardListProps) => { + const [nowPlaying, setNowPaying] = useState(null); + const { selectedMusic, setSelectedMusic, clearSelectedMusic } = + useMusicStore(); + + /** + * iframe에 비디오 아이디를 셋팅합니다. + * @param videoId :string + */ + const handlePlay = (youtubeId: string) => { + if (nowPlaying === youtubeId) { + setNowPaying(null); + console.log('노래 재생 중지'); + } else { + setNowPaying(youtubeId); + console.log('노래 재생 중 : ', youtubeId); + } + }; + + /** + * 사용자가 선택한 음악 정보를 셋팅합니다. + * @param item :MusicItem 클릭한 카드 컴포넌트 음악 정보 + */ + const handleClick = (item: MusicItem) => { + if (item === selectedMusic) { + clearSelectedMusic(); + } else { + setSelectedMusic(item); + } + }; + + // 테스트 + useEffect(() => { + console.log(selectedMusic); + }, [selectedMusic]); + + // TODO - iframe 유튜브 api 모듈로 변경 + return ( + + + {nowPlaying && ( +