diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4803f7b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,38 @@ + +## Code Rules +- Follow ESLint config defined in `./eslint.config.mjs` +- Max cyclomatic complexity per function: **5** +- Max parameters per function: **3** +- Max file length: **100 lines** — break into smaller files if exceeded +- Extract shared/common logic into utils +- Keep constants in a separate constants file +- Write proper test cases for every feature +- Build small features first before moving forward + + +DISTILLED_AESTHETICS_PROMPT = """ + +You tend to converge toward generic, "on distribution" outputs. In frontend design, this creates what users call the "AI slop" aesthetic. Avoid this: make creative, distinctive frontends that surprise and delight. Focus on: + +Typography: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics. + +Color & Theme: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. Draw from Music Application themes and cultural aesthetics for inspiration. + +Motion: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. + +Backgrounds: Create atmosphere and depth rather than defaulting to solid colors. Layer CSS gradients, use geometric patterns, or add contextual effects that match the overall aesthetic. + +Avoid generic AI-generated aesthetics: +- Overused font families (Inter, Roboto, Arial, system fonts) +- Clichéd color schemes (particularly purple gradients on white backgrounds) +- Predictable layouts and component patterns +- Cookie-cutter design that lacks context-specific character + +Interpret creatively and make unexpected choices that feel genuinely designed for the context. Vary between light and dark themes, different fonts, different aesthetics. You still tend to converge on common choices (Space Grotesk, for example) across generations. Avoid this: it is critical that you think outside the box! + +""" + + + + + diff --git a/app/components/ArtworkPlayButton/index.js b/app/components/ArtworkPlayButton/index.js new file mode 100644 index 0000000..4e8c3cd --- /dev/null +++ b/app/components/ArtworkPlayButton/index.js @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { PlayCircleFilled, PauseCircleFilled } from '@ant-design/icons'; +import { ArtworkWrapper, Artwork, PlayOverlay } from '@components/styled/artworkPlayButton'; + +const ArtworkPlayButton = ({ src, alt, isPlaying, isActive, onClick }) => { + const handleClick = (e) => { + e.stopPropagation(); + onClick(); + }; + + return ( + + + + {isActive && isPlaying ? : } + + + ); +}; + +ArtworkPlayButton.propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string.isRequired, + isPlaying: PropTypes.bool.isRequired, + isActive: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired +}; + +export default ArtworkPlayButton; diff --git a/app/components/ArtworkPlayButton/tests/index.test.js b/app/components/ArtworkPlayButton/tests/index.test.js new file mode 100644 index 0000000..e57b6f6 --- /dev/null +++ b/app/components/ArtworkPlayButton/tests/index.test.js @@ -0,0 +1,47 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import ArtworkPlayButton from '../index' + +describe('', () => { + const defaultProps = { + src: 'art.jpg', + alt: 'Song Art', + isPlaying: false, + isActive: false, + onClick: jest.fn() + } + + beforeEach(() => jest.clearAllMocks()) + + it('should render the artwork image', () => { + const { getByAltText } = renderProvider( + + ) + expect(getByAltText('Song Art')).toBeTruthy() + }) + + it('should show PlayCircleFilled when not active', () => { + const { getByTestId } = renderProvider( + + ) + expect(getByTestId('play-overlay')).toBeTruthy() + }) + + it('should show PauseCircleFilled when active and playing', () => { + const props = { ...defaultProps, isActive: true, isPlaying: true } + const { getByTestId } = renderProvider() + expect(getByTestId('play-overlay')).toBeTruthy() + }) + + it('should call onClick and stop propagation', () => { + const { getByTestId } = renderProvider( + + ) + const wrapper = getByTestId('artwork-play-btn') + const parentClick = jest.fn() + wrapper.parentElement.addEventListener('click', parentClick) + fireEvent.click(wrapper) + expect(defaultProps.onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/components/AudioPlayer/constants.js b/app/components/AudioPlayer/constants.js new file mode 100644 index 0000000..7e3dd91 --- /dev/null +++ b/app/components/AudioPlayer/constants.js @@ -0,0 +1 @@ +export const DEFAULT_VOLUME = 0.7; diff --git a/app/components/AudioPlayer/index.js b/app/components/AudioPlayer/index.js new file mode 100644 index 0000000..ae7e336 --- /dev/null +++ b/app/components/AudioPlayer/index.js @@ -0,0 +1,89 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + StepBackwardFilled, + PlayCircleFilled, + PauseCircleFilled, + StepForwardFilled, + SoundFilled +} from '@ant-design/icons'; +import { useAudioPlayer } from './useAudioPlayer'; +import { + PlayerContainer, + NowPlayingArt, + PlayerTrackInfo, + TrackTitle, + TrackArtist, + PlayerControls, + ControlButton, + ProgressSlider, + VolumeGroup, + VolumeSlider +} from '@components/styled/playerBar'; + +const AudioPlayer = ({ currentSong, onNext, onPrev, onPlayStateChange, onRegisterToggle }) => { + const player = useAudioPlayer(currentSong, onNext, onPlayStateChange); + + React.useEffect(() => { + if (onRegisterToggle) { + onRegisterToggle(player.togglePlay); + } + }, [player.togglePlay, onRegisterToggle]); + + if (!currentSong) { + return null; + } + + return ( + + + + {currentSong.trackName} + {currentSong.artistName} + + + + + + + {player.isPlaying ? : } + + + + + + player.seek(Number(e.target.value))} + style={{ '--fill': `${player.duration ? (player.currentTime / player.duration) * 100 : 0}%` }} + /> + + + player.setVolume(Number(e.target.value))} + style={{ '--fill': `${player.volume * 100}%` }} + /> + + + ); +}; + +AudioPlayer.propTypes = { + currentSong: PropTypes.object, + onNext: PropTypes.func.isRequired, + onPrev: PropTypes.func.isRequired, + onPlayStateChange: PropTypes.func, + onRegisterToggle: PropTypes.func +}; + +export default AudioPlayer; diff --git a/app/components/AudioPlayer/tests/index.test.js b/app/components/AudioPlayer/tests/index.test.js new file mode 100644 index 0000000..f6be60c --- /dev/null +++ b/app/components/AudioPlayer/tests/index.test.js @@ -0,0 +1,68 @@ +import React from 'react' +import { renderProvider } from '@utils/testUtils' +import AudioPlayer from '../index' + +jest.mock('../useAudioPlayer', () => ({ + useAudioPlayer: () => ({ + isPlaying: false, + currentTime: 0, + duration: 30, + volume: 0.7, + togglePlay: jest.fn(), + seek: jest.fn(), + setVolume: jest.fn() + }) +})) + +const mockSong = { + trackId: 1, + trackName: 'Test Song', + artistName: 'Test Artist', + artworkUrl: 'test.jpg', + previewUrl: 'test.mp3' +} + +describe('', () => { + const mockNext = jest.fn() + const mockPrev = jest.fn() + + it('should render nothing when no currentSong', () => { + const { container } = renderProvider( + + ) + expect(container.innerHTML).toBe('') + }) + + it('should render the player when a song is provided', () => { + const { getByTestId } = renderProvider( + + ) + expect(getByTestId('audio-player')).toBeTruthy() + }) + + it('should display track info', () => { + const { getByText } = renderProvider( + + ) + expect(getByText('Test Song')).toBeTruthy() + expect(getByText('Test Artist')).toBeTruthy() + }) + + it('should render all control buttons', () => { + const { getByTestId } = renderProvider( + + ) + expect(getByTestId('prev-btn')).toBeTruthy() + expect(getByTestId('play-btn')).toBeTruthy() + expect(getByTestId('next-btn')).toBeTruthy() + }) + + it('should render progress and volume sliders', () => { + const { getByTestId } = renderProvider( + + ) + expect(getByTestId('progress-slider')).toBeTruthy() + expect(getByTestId('volume-icon')).toBeTruthy() + expect(getByTestId('volume-slider')).toBeTruthy() + }) +}) diff --git a/app/components/AudioPlayer/tests/useAudioPlayer.test.js b/app/components/AudioPlayer/tests/useAudioPlayer.test.js new file mode 100644 index 0000000..078f058 --- /dev/null +++ b/app/components/AudioPlayer/tests/useAudioPlayer.test.js @@ -0,0 +1,69 @@ +import { renderHook, act } from '@testing-library/react' +import { useAudioPlayer } from '../useAudioPlayer' + +const mockPlay = jest.fn().mockResolvedValue(undefined) +const mockPause = jest.fn() +let mockAudioInstance + +beforeEach(() => { + mockPlay.mockClear() + mockPause.mockClear() + mockAudioInstance = { + play: mockPlay, + pause: mockPause, + volume: 0.7, + currentTime: 0, + duration: 0, + src: '', + addEventListener: jest.fn(), + removeEventListener: jest.fn() + } + jest.spyOn(global, 'Audio').mockImplementation(() => mockAudioInstance) +}) + +afterEach(() => { + global.Audio.mockRestore() +}) + +describe('useAudioPlayer', () => { + const song = { trackId: 1, previewUrl: 'http://test.mp3', trackName: 'Test' } + + it('should initialize with default values', () => { + const { result } = renderHook(() => useAudioPlayer(null, jest.fn())) + expect(result.current.isPlaying).toBe(false) + expect(result.current.volume).toBe(0.7) + expect(result.current.currentTime).toBe(0) + expect(result.current.duration).toBe(0) + }) + + it('should play when a song is provided', () => { + renderHook(() => useAudioPlayer(song, jest.fn())) + expect(mockAudioInstance.src).toBe(song.previewUrl) + expect(mockPlay).toHaveBeenCalled() + }) + + it('should toggle play/pause', () => { + const { result } = renderHook(() => useAudioPlayer(song, jest.fn())) + act(() => { + result.current.togglePlay() + }) + expect(mockPause).toHaveBeenCalled() + }) + + it('should update volume', () => { + const { result } = renderHook(() => useAudioPlayer(null, jest.fn())) + act(() => { + result.current.setVolume(0.5) + }) + expect(result.current.volume).toBe(0.5) + expect(mockAudioInstance.volume).toBe(0.5) + }) + + it('should seek to a given time', () => { + const { result } = renderHook(() => useAudioPlayer(null, jest.fn())) + act(() => { + result.current.seek(15) + }) + expect(mockAudioInstance.currentTime).toBe(15) + }) +}) diff --git a/app/components/AudioPlayer/useAudioPlayer.js b/app/components/AudioPlayer/useAudioPlayer.js new file mode 100644 index 0000000..5c7fa71 --- /dev/null +++ b/app/components/AudioPlayer/useAudioPlayer.js @@ -0,0 +1,81 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; +import { DEFAULT_VOLUME } from './constants'; + +export const useAudioPlayer = (song, onSongEnd, onPlayStateChange) => { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolumeState] = useState(DEFAULT_VOLUME); + + useEffect(() => { + const audio = new Audio(); + audio.volume = DEFAULT_VOLUME; + audioRef.current = audio; + + const onTime = () => setCurrentTime(audio.currentTime); + const onLoaded = () => setDuration(audio.duration); + const onEnded = () => { + setIsPlaying(false); + if (onPlayStateChange) { + onPlayStateChange(false); + } + if (onSongEnd) { + onSongEnd(); + } + }; + + audio.addEventListener('timeupdate', onTime); + audio.addEventListener('loadedmetadata', onLoaded); + audio.addEventListener('ended', onEnded); + + return () => { + audio.removeEventListener('timeupdate', onTime); + audio.removeEventListener('loadedmetadata', onLoaded); + audio.removeEventListener('ended', onEnded); + audio.pause(); + }; + }, []); + + useEffect(() => { + if (song?.previewUrl && audioRef.current) { + audioRef.current.src = song.previewUrl; + audioRef.current.play().catch(() => {}); + setIsPlaying(true); + if (onPlayStateChange) { + onPlayStateChange(true); + } + } + }, [song?.trackId]); + + const togglePlay = useCallback(() => { + if (!audioRef.current?.src) { + return; + } + const next = !isPlaying; + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play().catch(() => {}); + } + setIsPlaying(next); + if (onPlayStateChange) { + onPlayStateChange(next); + } + }, [isPlaying, onPlayStateChange]); + + const seek = useCallback((time) => { + if (audioRef.current) { + audioRef.current.currentTime = time; + } + }, []); + + const setVolume = useCallback((v) => { + setVolumeState(v); + if (audioRef.current) { + audioRef.current.volume = v; + } + }, []); + + return { isPlaying, currentTime, duration, volume, togglePlay, seek, setVolume }; +}; diff --git a/app/components/BackButton/index.js b/app/components/BackButton/index.js new file mode 100644 index 0000000..313b0fa --- /dev/null +++ b/app/components/BackButton/index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { BackBtn } from '@components/styled/backButton'; + +const BackButton = ({ onClick }) => ( + + + +); + +BackButton.propTypes = { + onClick: PropTypes.func.isRequired +}; + +export default BackButton; diff --git a/app/components/BackButton/tests/index.test.js b/app/components/BackButton/tests/index.test.js new file mode 100644 index 0000000..032e253 --- /dev/null +++ b/app/components/BackButton/tests/index.test.js @@ -0,0 +1,25 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import BackButton from '../index' + +describe('', () => { + it('should render the back button', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('back-button')).toBeTruthy() + }) + + it('should call onClick when clicked', () => { + const mockClick = jest.fn() + const { getByTestId } = renderProvider() + fireEvent.click(getByTestId('back-button')) + expect(mockClick).toHaveBeenCalledTimes(1) + }) + + it('should have accessible label', () => { + const { getByLabelText } = renderProvider( + + ) + expect(getByLabelText('Go back')).toBeTruthy() + }) +}) diff --git a/app/components/HeartButton/index.js b/app/components/HeartButton/index.js new file mode 100644 index 0000000..c4b6fb4 --- /dev/null +++ b/app/components/HeartButton/index.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { HeartFilled, HeartOutlined } from '@ant-design/icons'; +import { HeartBtn } from '@components/styled/heartButton'; + +const HeartButton = ({ isLiked, onClick }) => { + const handleClick = (e) => { + e.stopPropagation(); + onClick(); + }; + + return ( + + {isLiked ? : } + + ); +}; + +HeartButton.propTypes = { + isLiked: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired +}; + +export default HeartButton; diff --git a/app/components/HeartButton/tests/index.test.js b/app/components/HeartButton/tests/index.test.js new file mode 100644 index 0000000..d176e22 --- /dev/null +++ b/app/components/HeartButton/tests/index.test.js @@ -0,0 +1,46 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import HeartButton from '../index' + +describe('', () => { + const mockClick = jest.fn() + + beforeEach(() => { + mockClick.mockClear() + }) + + it('should render with outline icon when not liked', () => { + const { getByLabelText } = renderProvider( + + ) + expect(getByLabelText('Like song')).toBeTruthy() + }) + + it('should render with filled icon when liked', () => { + const { getByLabelText } = renderProvider( + + ) + expect(getByLabelText('Unlike song')).toBeTruthy() + }) + + it('should call onClick when clicked', () => { + const { getByTestId } = renderProvider( + + ) + fireEvent.click(getByTestId('heart-button')) + expect(mockClick).toHaveBeenCalledTimes(1) + }) + + it('should stop event propagation on click', () => { + const parentClick = jest.fn() + const { getByTestId } = renderProvider( +
+ +
+ ) + fireEvent.click(getByTestId('heart-button')) + expect(mockClick).toHaveBeenCalledTimes(1) + expect(parentClick).not.toHaveBeenCalled() + }) +}) diff --git a/app/components/LogoutButton/index.js b/app/components/LogoutButton/index.js new file mode 100644 index 0000000..e347a9f --- /dev/null +++ b/app/components/LogoutButton/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { LogoutOutlined } from '@ant-design/icons'; +import Router from 'next/router'; +import { clearStoredToken } from '@utils/authStorage'; +import { LogoutBtn } from '@components/styled/logoutButton'; + +const handleLogout = () => { + clearStoredToken(); + Router.push('/login'); +}; + +const LogoutButton = () => ( + + + +); + +export default LogoutButton; diff --git a/app/components/LogoutButton/tests/index.test.js b/app/components/LogoutButton/tests/index.test.js new file mode 100644 index 0000000..bd9b4bd --- /dev/null +++ b/app/components/LogoutButton/tests/index.test.js @@ -0,0 +1,42 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import { clearStoredToken } from '@utils/authStorage' +import Router from 'next/router' +import LogoutButton from '../index' + +jest.mock('@utils/authStorage', () => ({ + clearStoredToken: jest.fn() +})) + +jest.mock('next/router', () => ({ + push: jest.fn() +})) + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render the logout button', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('logout-button')).toBeTruthy() + }) + + it('should have the correct aria-label', () => { + const { getByLabelText } = renderProvider() + expect(getByLabelText('Log out')).toBeTruthy() + }) + + it('should clear token on click', () => { + const { getByTestId } = renderProvider() + fireEvent.click(getByTestId('logout-button')) + expect(clearStoredToken).toHaveBeenCalledTimes(1) + }) + + it('should redirect to login on click', () => { + const { getByTestId } = renderProvider() + fireEvent.click(getByTestId('logout-button')) + expect(Router.push).toHaveBeenCalledWith('/login') + }) +}) diff --git a/app/components/MusicVisual/constants.js b/app/components/MusicVisual/constants.js new file mode 100644 index 0000000..65cb9bc --- /dev/null +++ b/app/components/MusicVisual/constants.js @@ -0,0 +1,31 @@ +export const EQUALIZER_BARS = [ + { duration: 0.8, delay: 0 }, + { duration: 1.2, delay: 0.1 }, + { duration: 0.9, delay: 0.2 }, + { duration: 1.4, delay: 0.3 }, + { duration: 0.7, delay: 0.1 }, + { duration: 1.1, delay: 0.4 }, + { duration: 0.85, delay: 0.15 }, + { duration: 1.3, delay: 0.25 }, + { duration: 0.95, delay: 0.35 }, + { duration: 1.15, delay: 0.05 }, + { duration: 0.75, delay: 0.2 }, + { duration: 1.0, delay: 0.3 }, + { duration: 0.88, delay: 0.12 }, + { duration: 1.22, delay: 0.38 } +]; + +export const FLOATING_NOTES = [ + { note: '\u266A', size: 20, duration: 5, delay: 0, left: 18, bottom: 15 }, + { note: '\u266B', size: 28, duration: 7, delay: 2, left: 72, bottom: 20 }, + { note: '\u266A', size: 16, duration: 6, delay: 4, left: 38, bottom: 10 }, + { note: '\u266B', size: 24, duration: 8, delay: 1, left: 82, bottom: 28 }, + { note: '\u266A', size: 18, duration: 5.5, delay: 3, left: 12, bottom: 32 }, + { note: '\u266B', size: 22, duration: 6.5, delay: 5, left: 55, bottom: 8 } +]; + +export const GLOW_RINGS = [ + { size: 300, duration: 3, delay: 0 }, + { size: 370, duration: 4, delay: 0.5 }, + { size: 440, duration: 5, delay: 1 } +]; diff --git a/app/components/MusicVisual/index.js b/app/components/MusicVisual/index.js new file mode 100644 index 0000000..516f46c --- /dev/null +++ b/app/components/MusicVisual/index.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { + VinylRecord, + GlowRing, + EqualizerWrapper, + EqualizerBar, + FloatingNote, + BrandText, + TaglineText +} from '@components/styled/musicVisual'; +import { EQUALIZER_BARS, FLOATING_NOTES, GLOW_RINGS } from './constants'; + +const MusicVisual = () => ( + <> + MUSICA + FEEL THE RHYTHM + {GLOW_RINGS.map((ring) => ( + + ))} + + + {EQUALIZER_BARS.map((bar, i) => ( + + ))} + + {FLOATING_NOTES.map((note, i) => ( + + {note.note} + + ))} + +); + +export default MusicVisual; diff --git a/app/components/MusicVisual/tests/__snapshots__/index.test.js.snap b/app/components/MusicVisual/tests/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..aa7c0f8 --- /dev/null +++ b/app/components/MusicVisual/tests/__snapshots__/index.test.js.snap @@ -0,0 +1,1015 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +@keyframes animation-0 { + 0%, 100% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + opacity: 0.15; + } + + 50% { + -webkit-transform: scale(1.08); + -moz-transform: scale(1.08); + -ms-transform: scale(1.08); + transform: scale(1.08); + opacity: 0.35; + } +} + +@keyframes animation-0 { + 0%, 100% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + opacity: 0.15; + } + + 50% { + -webkit-transform: scale(1.08); + -moz-transform: scale(1.08); + -ms-transform: scale(1.08); + transform: scale(1.08); + opacity: 0.35; + } +} + +@keyframes animation-0 { + 0%, 100% { + -webkit-transform: scale(1); + -moz-transform: scale(1); + -ms-transform: scale(1); + transform: scale(1); + opacity: 0.15; + } + + 50% { + -webkit-transform: scale(1.08); + -moz-transform: scale(1.08); + -ms-transform: scale(1.08); + transform: scale(1.08); + opacity: 0.35; + } +} + +@keyframes animation-1 { + from { + -webkit-transform: rotate(0deg); + -moz-transform: rotate(0deg); + -ms-transform: rotate(0deg); + transform: rotate(0deg); + } + + to { + -webkit-transform: rotate(360deg); + -moz-transform: rotate(360deg); + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-2 { + 0%, 100% { + height: 15%; + } + + 25% { + height: 55%; + } + + 50% { + height: 85%; + } + + 75% { + height: 35%; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +@keyframes animation-3 { + 0% { + -webkit-transform: translateY(0) rotate(0deg); + -moz-transform: translateY(0) rotate(0deg); + -ms-transform: translateY(0) rotate(0deg); + transform: translateY(0) rotate(0deg); + opacity: 0.7; + } + + 100% { + -webkit-transform: translateY(-280px) rotate(40deg); + -moz-transform: translateY(-280px) rotate(40deg); + -ms-transform: translateY(-280px) rotate(40deg); + transform: translateY(-280px) rotate(40deg); + opacity: 0; + } +} + +.emotion-0 { + font-family: 'Syne',sans-serif; + font-size: 3.2rem; + font-weight: 800; + letter-spacing: 0.3em; + color: var(--musica-text); + margin-bottom: 0.5rem; + z-index: 3; + text-shadow: 0 0 40px rgba(255, 107, 53, 0.25); +} + +.emotion-1 { + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + color: var(--musica-muted); + letter-spacing: 0.25em; + z-index: 3; + margin-top: 0; + margin-bottom: 2.5rem; +} + +.emotion-2 { + position: absolute; + width: 300px; + height: 300px; + border-radius: 50%; + border: 1px solid rgba(255, 107, 53, 0.08); + -webkit-animation: animation-0 3s ease-in-out infinite; + animation: animation-0 3s ease-in-out infinite; + -webkit-animation-delay: 0s; + animation-delay: 0s; +} + +.emotion-3 { + position: absolute; + width: 370px; + height: 370px; + border-radius: 50%; + border: 1px solid rgba(255, 107, 53, 0.08); + -webkit-animation: animation-0 4s ease-in-out infinite; + animation: animation-0 4s ease-in-out infinite; + -webkit-animation-delay: 0.5s; + animation-delay: 0.5s; +} + +.emotion-4 { + position: absolute; + width: 440px; + height: 440px; + border-radius: 50%; + border: 1px solid rgba(255, 107, 53, 0.08); + -webkit-animation: animation-0 5s ease-in-out infinite; + animation: animation-0 5s ease-in-out infinite; + -webkit-animation-delay: 1s; + animation-delay: 1s; +} + +.emotion-5 { + width: 260px; + height: 260px; + border-radius: 50%; + background: radial-gradient( + circle, + #1a1a2e 0%, + #0d0d0d 20%, + #2d1b4e 21%, + #0d0d0d 40%, + #2d1b4e 41%, + #0d0d0d 60%, + #2d1b4e 61%, + #0d0d0d 80%, + #1a1a2e 100% + ); + -webkit-animation: animation-1 8s linear infinite; + animation: animation-1 8s linear infinite; + position: relative; + z-index: 2; + box-shadow: 0 0 60px rgba(255, 107, 53, 0.12); + cursor: pointer; + -webkit-transition: box-shadow 0.4s ease; + transition: box-shadow 0.4s ease; +} + +.emotion-5:hover { + -webkit-animation-duration: 2s; + animation-duration: 2s; + box-shadow: 0 0 90px rgba(232, 67, 147, 0.25); +} + +.emotion-5::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + -webkit-transform: translate(-50%, -50%); + -moz-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + width: 55px; + height: 55px; + border-radius: 50%; + background: radial-gradient(circle, #ff6b35 0%, #e84393 100%); + box-shadow: 0 0 25px rgba(255, 107, 53, 0.5); +} + +.emotion-6 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-align-items: flex-end; + -webkit-box-align: flex-end; + -ms-flex-align: flex-end; + align-items: flex-end; + gap: 5px; + height: 100px; + position: absolute; + bottom: 70px; + z-index: 1; +} + +.emotion-7 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.8s ease-in-out infinite; + animation: animation-2 0.8s ease-in-out infinite; + -webkit-animation-delay: 0s; + animation-delay: 0s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-7:hover { + opacity: 1; +} + +.emotion-8 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.2s ease-in-out infinite; + animation: animation-2 1.2s ease-in-out infinite; + -webkit-animation-delay: 0.1s; + animation-delay: 0.1s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-8:hover { + opacity: 1; +} + +.emotion-9 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.9s ease-in-out infinite; + animation: animation-2 0.9s ease-in-out infinite; + -webkit-animation-delay: 0.2s; + animation-delay: 0.2s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-9:hover { + opacity: 1; +} + +.emotion-10 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.4s ease-in-out infinite; + animation: animation-2 1.4s ease-in-out infinite; + -webkit-animation-delay: 0.3s; + animation-delay: 0.3s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-10:hover { + opacity: 1; +} + +.emotion-11 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.7s ease-in-out infinite; + animation: animation-2 0.7s ease-in-out infinite; + -webkit-animation-delay: 0.1s; + animation-delay: 0.1s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-11:hover { + opacity: 1; +} + +.emotion-12 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.1s ease-in-out infinite; + animation: animation-2 1.1s ease-in-out infinite; + -webkit-animation-delay: 0.4s; + animation-delay: 0.4s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-12:hover { + opacity: 1; +} + +.emotion-13 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.85s ease-in-out infinite; + animation: animation-2 0.85s ease-in-out infinite; + -webkit-animation-delay: 0.15s; + animation-delay: 0.15s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-13:hover { + opacity: 1; +} + +.emotion-14 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.3s ease-in-out infinite; + animation: animation-2 1.3s ease-in-out infinite; + -webkit-animation-delay: 0.25s; + animation-delay: 0.25s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-14:hover { + opacity: 1; +} + +.emotion-15 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.95s ease-in-out infinite; + animation: animation-2 0.95s ease-in-out infinite; + -webkit-animation-delay: 0.35s; + animation-delay: 0.35s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-15:hover { + opacity: 1; +} + +.emotion-16 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.15s ease-in-out infinite; + animation: animation-2 1.15s ease-in-out infinite; + -webkit-animation-delay: 0.05s; + animation-delay: 0.05s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-16:hover { + opacity: 1; +} + +.emotion-17 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.75s ease-in-out infinite; + animation: animation-2 0.75s ease-in-out infinite; + -webkit-animation-delay: 0.2s; + animation-delay: 0.2s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-17:hover { + opacity: 1; +} + +.emotion-18 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1s ease-in-out infinite; + animation: animation-2 1s ease-in-out infinite; + -webkit-animation-delay: 0.3s; + animation-delay: 0.3s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-18:hover { + opacity: 1; +} + +.emotion-19 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 0.88s ease-in-out infinite; + animation: animation-2 0.88s ease-in-out infinite; + -webkit-animation-delay: 0.12s; + animation-delay: 0.12s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-19:hover { + opacity: 1; +} + +.emotion-20 { + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + -webkit-animation: animation-2 1.22s ease-in-out infinite; + animation: animation-2 1.22s ease-in-out infinite; + -webkit-animation-delay: 0.38s; + animation-delay: 0.38s; + opacity: 0.6; + -webkit-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; +} + +.emotion-20:hover { + opacity: 1; +} + +.emotion-21 { + position: absolute; + font-size: 20px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 5s ease-out infinite; + animation: animation-3 5s ease-out infinite; + -webkit-animation-delay: 0s; + animation-delay: 0s; + left: 18%; + bottom: 15%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-22 { + position: absolute; + font-size: 28px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 7s ease-out infinite; + animation: animation-3 7s ease-out infinite; + -webkit-animation-delay: 2s; + animation-delay: 2s; + left: 72%; + bottom: 20%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-23 { + position: absolute; + font-size: 16px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 6s ease-out infinite; + animation: animation-3 6s ease-out infinite; + -webkit-animation-delay: 4s; + animation-delay: 4s; + left: 38%; + bottom: 10%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-24 { + position: absolute; + font-size: 24px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 8s ease-out infinite; + animation: animation-3 8s ease-out infinite; + -webkit-animation-delay: 1s; + animation-delay: 1s; + left: 82%; + bottom: 28%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-25 { + position: absolute; + font-size: 18px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 5.5s ease-out infinite; + animation: animation-3 5.5s ease-out infinite; + -webkit-animation-delay: 3s; + animation-delay: 3s; + left: 12%; + bottom: 32%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-26 { + position: absolute; + font-size: 22px; + color: rgba(255, 107, 53, 0.35); + -webkit-animation: animation-3 6.5s ease-out infinite; + animation: animation-3 6.5s ease-out infinite; + -webkit-animation-delay: 5s; + animation-delay: 5s; + left: 55%; + bottom: 8%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + + +
+

+ MUSICA +

+

+ FEEL THE RHYTHM +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ♪ +
+
+ ♫ +
+
+ ♪ +
+
+ ♫ +
+
+ ♪ +
+
+ ♫ +
+
+ +`; diff --git a/app/components/MusicVisual/tests/index.test.js b/app/components/MusicVisual/tests/index.test.js new file mode 100644 index 0000000..a80d180 --- /dev/null +++ b/app/components/MusicVisual/tests/index.test.js @@ -0,0 +1,25 @@ +import React from 'react' +import { renderProvider } from '@utils/testUtils' +import MusicVisual from '../index' + +describe('', () => { + it('should render and match the snapshot', () => { + const { baseElement } = renderProvider() + expect(baseElement).toMatchSnapshot() + }) + + it('should render the vinyl record', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('vinyl-record')).toBeTruthy() + }) + + it('should render the brand text', () => { + const { getByText } = renderProvider() + expect(getByText('MUSICA')).toBeTruthy() + }) + + it('should render the tagline', () => { + const { getByText } = renderProvider() + expect(getByText('FEEL THE RHYTHM')).toBeTruthy() + }) +}) diff --git a/app/components/NavLink/index.js b/app/components/NavLink/index.js new file mode 100644 index 0000000..94d6d95 --- /dev/null +++ b/app/components/NavLink/index.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Link from 'next/link'; +import { NavLinkStyled } from '@components/styled/navLink'; + +const NavLink = ({ href, label, isActive }) => ( + + + {label} + + +); + +NavLink.propTypes = { + href: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isActive: PropTypes.bool +}; + +export default NavLink; diff --git a/app/components/NavLink/tests/index.test.js b/app/components/NavLink/tests/index.test.js new file mode 100644 index 0000000..f7399e2 --- /dev/null +++ b/app/components/NavLink/tests/index.test.js @@ -0,0 +1,31 @@ +import React from 'react' +import { renderProvider } from '@utils/testUtils' +import NavLink from '../index' + +describe('', () => { + it('should render the label text', () => { + const { getByText } = renderProvider() + expect(getByText('Search')).toBeTruthy() + }) + + it('should have the correct test id', () => { + const { getByTestId } = renderProvider( + + ) + expect(getByTestId('nav-library')).toBeTruthy() + }) + + it('should render with active styling prop', () => { + const { getByTestId } = renderProvider( + + ) + expect(getByTestId('nav-search')).toBeTruthy() + }) + + it('should link to the correct href', () => { + const { getByTestId } = renderProvider( + + ) + expect(getByTestId('nav-library').getAttribute('href')).toBe('/library') + }) +}) diff --git a/app/components/SearchBar/index.js b/app/components/SearchBar/index.js new file mode 100644 index 0000000..f980ce5 --- /dev/null +++ b/app/components/SearchBar/index.js @@ -0,0 +1,24 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { SearchContainer, SearchInput } from '@components/styled/musicSearch'; + +const SearchBar = ({ value, onChange, loading }) => ( + + onChange(e.target.value)} + disabled={loading} + /> + +); + +SearchBar.propTypes = { + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + loading: PropTypes.bool +}; + +export default SearchBar; diff --git a/app/components/SearchBar/tests/__snapshots__/index.test.js.snap b/app/components/SearchBar/tests/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..ecfa591 --- /dev/null +++ b/app/components/SearchBar/tests/__snapshots__/index.test.js.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +.emotion-0 { + width: 100%; + margin-bottom: 2rem; + position: relative; +} + +.emotion-1 { + width: 100%; + padding: 1rem 1.25rem; + background: var(--musica-inputBg); + border: 1.5px solid var(--musica-border); + border-radius: 12px; + color: var(--musica-text); + font-family: 'Outfit',sans-serif; + font-size: 1rem; + outline: none; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.emotion-1::-webkit-input-placeholder { + color: var(--musica-placeholder); +} + +.emotion-1::-moz-placeholder { + color: var(--musica-placeholder); +} + +.emotion-1:-ms-input-placeholder { + color: var(--musica-placeholder); +} + +.emotion-1::placeholder { + color: var(--musica-placeholder); +} + +.emotion-1:focus { + border-color: var(--musica-accent); + box-shadow: 0 0 24px rgba(255, 107, 53, 0.12); +} + +.emotion-1:hover:not(:focus) { + border-color: #3d3d54; +} + +.emotion-1:disabled { + opacity: 0.5; + cursor: not-allowed; +} + + +
+
+ +
+
+ +`; diff --git a/app/components/SearchBar/tests/index.test.js b/app/components/SearchBar/tests/index.test.js new file mode 100644 index 0000000..5f94056 --- /dev/null +++ b/app/components/SearchBar/tests/index.test.js @@ -0,0 +1,37 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import SearchBar from '../index' + +describe('', () => { + const mockChange = jest.fn() + const defaultProps = { value: '', onChange: mockChange, loading: false } + + beforeEach(() => { + mockChange.mockClear() + }) + + it('should render and match the snapshot', () => { + const { baseElement } = renderProvider() + expect(baseElement).toMatchSnapshot() + }) + + it('should render the search input', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('music-search-input')).toBeTruthy() + }) + + it('should call onChange when typing', () => { + const { getByTestId } = renderProvider() + fireEvent.change(getByTestId('music-search-input'), { + target: { value: 'hello' } + }) + expect(mockChange).toHaveBeenCalledWith('hello') + }) + + it('should be disabled when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('music-search-input')).toBeDisabled() + }) +}) diff --git a/app/components/SongList/index.js b/app/components/SongList/index.js new file mode 100644 index 0000000..9f520c2 --- /dev/null +++ b/app/components/SongList/index.js @@ -0,0 +1,64 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useRouter } from 'next/router'; +import HeartButton from '@components/HeartButton'; +import ArtworkPlayButton from '@components/ArtworkPlayButton'; +import If from '@components/If'; +import { SongListWrapper, SongCard, SongInfo, SongTitle, SongArtist, SongAlbum } from '@components/styled/songList'; + +const SongList = ({ songs, currentSong, isPlaying, onPlayToggle, likedTrackIds, onToggleLike }) => { + const router = useRouter(); + + const handleCardClick = (song) => { + router.push(`/track/${song.trackId}`); + }; + + return ( + + {songs.map((song) => { + const isActive = currentSong?.trackId === song.trackId; + return ( + handleCardClick(song)} + > + onPlayToggle(song)} + /> + + {song.trackName} + {song.artistName} + {song.albumName} + + + onToggleLike(song)} /> + + + ); + })} + + ); +}; + +SongList.propTypes = { + songs: PropTypes.array.isRequired, + currentSong: PropTypes.object, + isPlaying: PropTypes.bool, + onPlayToggle: PropTypes.func.isRequired, + likedTrackIds: PropTypes.object, + onToggleLike: PropTypes.func +}; + +SongList.defaultProps = { + isPlaying: false, + likedTrackIds: {}, + onToggleLike: null +}; + +export default SongList; diff --git a/app/components/SongList/tests/index.test.js b/app/components/SongList/tests/index.test.js new file mode 100644 index 0000000..e6597a3 --- /dev/null +++ b/app/components/SongList/tests/index.test.js @@ -0,0 +1,108 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import SongList from '../index' + +const mockPush = jest.fn() +jest.mock('next/router', () => ({ + useRouter: () => ({ push: mockPush }) +})) + +jest.mock('@components/ArtworkPlayButton', () => { + const PT = require('prop-types') + const Mock = ({ onClick, isPlaying, isActive }) => ( + + ) + Mock.displayName = 'MockArtworkPlayButton' + Mock.propTypes = { onClick: PT.func, isPlaying: PT.bool, isActive: PT.bool } + return Mock +}) + +const mockSongs = [ + { + trackId: 1, + trackName: 'Song A', + artistName: 'Artist A', + albumName: 'Album A', + artworkUrl: 'a.jpg' + }, + { + trackId: 2, + trackName: 'Song B', + artistName: 'Artist B', + albumName: 'Album B', + artworkUrl: 'b.jpg' + } +] + +describe('', () => { + const mockPlayToggle = jest.fn() + const mockToggleLike = jest.fn() + const defaultProps = { + songs: mockSongs, + currentSong: null, + isPlaying: false, + onPlayToggle: mockPlayToggle + } + + beforeEach(() => jest.clearAllMocks()) + + it('should render all songs', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('song-1')).toBeTruthy() + expect(getByTestId('song-2')).toBeTruthy() + }) + + it('should navigate to track detail when card clicked', () => { + const { getByTestId } = renderProvider() + fireEvent.click(getByTestId('song-1')) + expect(mockPush).toHaveBeenCalledWith('/track/1') + }) + + it('should call onPlayToggle when artwork clicked', () => { + const { getAllByTestId } = renderProvider() + fireEvent.click(getAllByTestId('artwork-play-btn')[0]) + expect(mockPlayToggle).toHaveBeenCalledWith(mockSongs[0]) + }) + + it('should render song details', () => { + const { getByText } = renderProvider() + expect(getByText('Song A')).toBeTruthy() + expect(getByText('Artist A')).toBeTruthy() + expect(getByText('Album A')).toBeTruthy() + }) + + it('should render empty list when no songs', () => { + const props = { ...defaultProps, songs: [] } + const { getByTestId } = renderProvider() + expect(getByTestId('song-list').children.length).toBe(0) + }) + + it('should not render heart buttons without onToggleLike', () => { + const { queryAllByTestId } = renderProvider() + expect(queryAllByTestId('heart-button')).toHaveLength(0) + }) + + it('should render heart buttons when onToggleLike is provided', () => { + const props = { + ...defaultProps, + onToggleLike: mockToggleLike, + likedTrackIds: {} + } + const { getAllByTestId } = renderProvider() + expect(getAllByTestId('heart-button')).toHaveLength(2) + }) + + it('should call onToggleLike with song data when heart clicked', () => { + const props = { + ...defaultProps, + onToggleLike: mockToggleLike, + likedTrackIds: {} + } + const { getAllByTestId } = renderProvider() + fireEvent.click(getAllByTestId('heart-button')[0]) + expect(mockToggleLike).toHaveBeenCalledWith(mockSongs[0]) + }) +}) diff --git a/app/components/ThemeToggle/index.js b/app/components/ThemeToggle/index.js new file mode 100644 index 0000000..21508ca --- /dev/null +++ b/app/components/ThemeToggle/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { SunFilled, MoonFilled } from '@ant-design/icons'; +import { useTheme } from '@app/contexts/ThemeContext'; +import { THEME_DARK } from '@app/themes/palettes'; +import { ToggleButton } from '@components/styled/themeToggle'; + +const ThemeToggle = () => { + const { themeMode, toggleTheme } = useTheme(); + const isDark = themeMode === THEME_DARK; + + return ( + + {isDark ? : } + + ); +}; + +export default ThemeToggle; diff --git a/app/components/ThemeToggle/tests/index.test.js b/app/components/ThemeToggle/tests/index.test.js new file mode 100644 index 0000000..791c512 --- /dev/null +++ b/app/components/ThemeToggle/tests/index.test.js @@ -0,0 +1,28 @@ +import React from 'react' +import { render, fireEvent } from '@testing-library/react' +import { ThemeProvider } from '@app/contexts/ThemeContext' +import ThemeToggle from '../index' + +const renderWithTheme = (ui) => render({ui}) + +describe('', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('should render the toggle button', () => { + const { getByTestId } = renderWithTheme() + expect(getByTestId('theme-toggle')).toBeTruthy() + }) + + it('should have accessible label', () => { + const { getByLabelText } = renderWithTheme() + expect(getByLabelText('Switch to light theme')).toBeTruthy() + }) + + it('should switch label after click', () => { + const { getByTestId, getByLabelText } = renderWithTheme() + fireEvent.click(getByTestId('theme-toggle')) + expect(getByLabelText('Switch to dark theme')).toBeTruthy() + }) +}) diff --git a/app/components/TrackInfo/index.js b/app/components/TrackInfo/index.js new file mode 100644 index 0000000..55147c8 --- /dev/null +++ b/app/components/TrackInfo/index.js @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { formatDuration } from '@utils/formatDuration'; +import { + TrackDetailWrapper, + TrackArtworkLarge, + TrackMeta, + TrackDetailName, + TrackDetailArtist, + TrackDetailAlbum, + TagRow, + Tag, + StoreLink +} from '@components/styled/trackInfo'; + +const TrackInfo = ({ track }) => ( + + + + {track.trackName} + {track.artistName} + {track.albumName} + + + {{track.genre}} + {track.durationMs && {formatDuration(track.durationMs)}} + {track.releaseDate && {new Date(track.releaseDate).getFullYear()}} + {track.trackPrice && ( + + {track.currency} {track.trackPrice} + + )} + + {track.trackUrl && ( + + Open in Store + + )} + +); + +TrackInfo.propTypes = { + track: PropTypes.object.isRequired +}; + +export default TrackInfo; diff --git a/app/components/TrackInfo/tests/index.test.js b/app/components/TrackInfo/tests/index.test.js new file mode 100644 index 0000000..97a64ff --- /dev/null +++ b/app/components/TrackInfo/tests/index.test.js @@ -0,0 +1,56 @@ +import React from 'react' +import { renderProvider } from '@utils/testUtils' +import TrackInfo from '../index' + +const mockTrack = { + trackId: 123, + trackName: 'Metamorphosis', + artistName: 'emi', + albumName: 'Metamorphosis - Single', + artworkUrl: 'art.jpg', + genre: 'Pop', + durationMs: 210000, + releaseDate: '2024-01-15T00:00:00Z', + trackPrice: 1.29, + currency: 'USD', + trackUrl: 'https://music.apple.com/track/123' +} + +describe('', () => { + it('should render track name and artist', () => { + const { getByText } = renderProvider() + expect(getByText('Metamorphosis')).toBeTruthy() + expect(getByText('emi')).toBeTruthy() + }) + + it('should render album name', () => { + const { getByText } = renderProvider() + expect(getByText('Metamorphosis - Single')).toBeTruthy() + }) + + it('should render genre tag', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('tag-genre').textContent).toBe('Pop') + }) + + it('should render formatted duration', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('tag-duration').textContent).toBe('3:30') + }) + + it('should render release year', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('tag-release').textContent).toBe('2024') + }) + + it('should render price tag', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('tag-price').textContent).toBe('USD 1.29') + }) + + it('should render store link', () => { + const { getByTestId } = renderProvider() + const link = getByTestId('store-link') + expect(link.getAttribute('href')).toBe('https://music.apple.com/track/123') + }) +}) diff --git a/app/components/styled/artworkPlayButton.js b/app/components/styled/artworkPlayButton.js new file mode 100644 index 0000000..151ef69 --- /dev/null +++ b/app/components/styled/artworkPlayButton.js @@ -0,0 +1,35 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const ArtworkWrapper = styled.div` + position: relative; + width: 50px; + height: 50px; + flex-shrink: 0; + cursor: pointer; + border-radius: 6px; + overflow: hidden; + &:hover > [data-overlay] { + opacity: 1; + } +`; + +export const Artwork = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + display: block; +`; + +export const PlayOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.45); + opacity: ${(p) => (p.isActive ? 1 : 0)}; + transition: opacity 0.2s ease; + font-size: 1.4rem; + color: ${C.accent}; +`; diff --git a/app/components/styled/authForm.js b/app/components/styled/authForm.js new file mode 100644 index 0000000..f423db1 --- /dev/null +++ b/app/components/styled/authForm.js @@ -0,0 +1,118 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const FormCard = styled.div` + width: 100%; + max-width: 420px; + padding: 3rem; + background: ${C.bg}; + border-radius: 16px; + border: 1px solid ${C.border}; +`; + +export const FormTitle = styled.h2` + font-family: 'Syne', sans-serif; + font-size: 2rem; + font-weight: 700; + color: ${C.text}; + margin-bottom: 0.5rem; +`; + +export const FormSubtitle = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.95rem; + color: ${C.muted}; + margin-bottom: 2rem; +`; + +export const InputLabel = styled.label` + font-family: 'Outfit', sans-serif; + font-size: 0.85rem; + color: ${C.label}; + display: block; + margin-bottom: 0.5rem; +`; + +export const StyledInput = styled.input` + width: 100%; + padding: 0.85rem 1rem; + background: ${C.inputBg}; + border: 1.5px solid ${C.border}; + border-radius: 10px; + color: ${C.text}; + font-family: 'Outfit', sans-serif; + font-size: 0.95rem; + outline: none; + transition: all 0.3s ease; + box-sizing: border-box; + &::placeholder { + color: ${C.placeholder}; + } + &:focus { + border-color: ${C.accent}; + box-shadow: 0 0 20px rgba(255, 107, 53, 0.15); + } + &:hover:not(:focus) { + border-color: #3d3d54; + } +`; + +export const InputWrapper = styled.div` + margin-bottom: 1.25rem; +`; + +export const SubmitButton = styled.button` + width: 100%; + padding: 0.9rem; + background: linear-gradient(135deg, ${C.accent}, ${C.pink}); + border: none; + border-radius: 10px; + color: ${C.text}; + font-family: 'Syne', sans-serif; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 0.5rem; + letter-spacing: 0.05em; + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(255, 107, 53, 0.3); + } + &:active { + transform: translateY(0); + } + &:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } +`; + +export const SwitchText = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.9rem; + color: ${C.muted}; + text-align: center; + margin-top: 1.5rem; +`; + +export const SwitchLink = styled.a` + color: ${C.accent}; + text-decoration: none; + font-weight: 500; + cursor: pointer; + transition: color 0.2s ease; + &:hover { + color: ${C.pink}; + text-decoration: underline; + } +`; + +export const ErrorMessage = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.85rem; + color: ${C.error}; + margin-top: 0.5rem; + text-align: center; +`; diff --git a/app/components/styled/authLayout.js b/app/components/styled/authLayout.js new file mode 100644 index 0000000..dac4747 --- /dev/null +++ b/app/components/styled/authLayout.js @@ -0,0 +1,46 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const AuthPageWrapper = styled.div` + display: flex; + min-height: 100vh; + width: 100%; + background: ${C.bg}; + overflow: hidden; +`; + +export const VisualPanel = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + background: linear-gradient(135deg, ${C.bg} 0%, ${C.surface} 50%, ${C.bg} 100%); + overflow: hidden; + + @media (max-width: 768px) { + display: none; + } +`; + +export const FormPanel = styled.div` + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + background: ${C.bg}; + position: relative; + + @media (max-width: 768px) { + width: 100%; + } +`; + +export const FormPanelToggle = styled.div` + position: absolute; + top: 1.5rem; + right: 1.5rem; +`; diff --git a/app/components/styled/backButton.js b/app/components/styled/backButton.js new file mode 100644 index 0000000..a758bc5 --- /dev/null +++ b/app/components/styled/backButton.js @@ -0,0 +1,19 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const BackBtn = styled.button` + background: none; + border: none; + color: ${C.text}; + font-size: 1.2rem; + cursor: pointer; + padding: 0.5rem; + border-radius: 8px; + display: flex; + align-items: center; + transition: all 0.2s ease; + &:hover { + color: ${C.accent}; + transform: translateX(-2px); + } +`; diff --git a/app/components/styled/colors.js b/app/components/styled/colors.js new file mode 100644 index 0000000..e6e5612 --- /dev/null +++ b/app/components/styled/colors.js @@ -0,0 +1,14 @@ +export const C = { + bg: 'var(--musica-bg)', + cardBg: 'var(--musica-cardBg)', + inputBg: 'var(--musica-inputBg)', + border: 'var(--musica-border)', + accent: 'var(--musica-accent)', + pink: 'var(--musica-pink)', + text: 'var(--musica-text)', + muted: 'var(--musica-muted)', + label: 'var(--musica-label)', + placeholder: 'var(--musica-placeholder)', + error: 'var(--musica-error)', + surface: 'var(--musica-surface)' +}; diff --git a/app/components/styled/heartButton.js b/app/components/styled/heartButton.js new file mode 100644 index 0000000..24df97a --- /dev/null +++ b/app/components/styled/heartButton.js @@ -0,0 +1,31 @@ +import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; +import { C } from './colors'; + +const heartPop = keyframes` + 0% { transform: scale(1); } + 50% { transform: scale(1.35); } + 100% { transform: scale(1); } +`; + +export const HeartBtn = styled.button` + background: transparent; + border: none; + color: ${(p) => (p.isLiked ? '#e84393' : C.muted)}; + font-size: 1.15rem; + cursor: pointer; + padding: 0.4rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: + color 0.2s ease, + transform 0.2s ease; + flex-shrink: 0; + animation: ${(p) => (p.isLiked ? heartPop : 'none')} 0.35s ease; + &:hover { + color: #e84393; + transform: scale(1.2); + } +`; diff --git a/app/components/styled/logoutButton.js b/app/components/styled/logoutButton.js new file mode 100644 index 0000000..054024b --- /dev/null +++ b/app/components/styled/logoutButton.js @@ -0,0 +1,27 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const LogoutBtn = styled.button` + background: transparent; + border: 1px solid ${C.border}; + color: ${C.muted}; + width: 38px; + height: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + cursor: pointer; + transition: all 0.3s ease; + flex-shrink: 0; + &:hover { + color: ${C.error}; + border-color: ${C.error}; + box-shadow: 0 0 16px rgba(255, 71, 87, 0.2); + transform: scale(1.1); + } + &:active { + transform: scale(0.95); + } +`; diff --git a/app/components/styled/musicPage.js b/app/components/styled/musicPage.js new file mode 100644 index 0000000..14d0955 --- /dev/null +++ b/app/components/styled/musicPage.js @@ -0,0 +1,63 @@ +import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; +import { C } from './colors'; + +const spinGradient = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +export const MusicPageWrapper = styled.div` + min-height: 100vh; + background: ${C.bg}; + color: ${C.text}; + padding-bottom: 100px; + position: relative; +`; + +export const MusicPageContent = styled.div` + max-width: 900px; + width: 100%; + margin: 0 auto; + padding: 2rem 1.5rem; +`; + +export const PageHeader = styled.header` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 2rem; +`; + +export const PageTitle = styled.h1` + font-family: 'Syne', sans-serif; + font-size: 2.2rem; + font-weight: 800; + letter-spacing: 0.2em; + color: ${C.text}; + text-shadow: 0 0 30px rgba(255, 107, 53, 0.2); +`; + +export const EmptyState = styled.div` + text-align: center; + padding: 4rem 1rem; + font-family: 'Outfit', sans-serif; + color: ${C.muted}; + font-size: 1rem; +`; + +export const HeaderActions = styled.div` + display: flex; + align-items: center; + gap: 0.6rem; +`; + +export const LoadingSpinner = styled.div` + width: 36px; + height: 36px; + margin: 3rem auto; + border-radius: 50%; + border: 3px solid ${C.border}; + border-top-color: ${C.accent}; + animation: ${spinGradient} 0.8s linear infinite; +`; diff --git a/app/components/styled/musicSearch.js b/app/components/styled/musicSearch.js new file mode 100644 index 0000000..b0265e5 --- /dev/null +++ b/app/components/styled/musicSearch.js @@ -0,0 +1,36 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const SearchContainer = styled.div` + width: 100%; + margin-bottom: 2rem; + position: relative; +`; + +export const SearchInput = styled.input` + width: 100%; + padding: 1rem 1.25rem; + background: ${C.inputBg}; + border: 1.5px solid ${C.border}; + border-radius: 12px; + color: ${C.text}; + font-family: 'Outfit', sans-serif; + font-size: 1rem; + outline: none; + transition: all 0.3s ease; + box-sizing: border-box; + &::placeholder { + color: ${C.placeholder}; + } + &:focus { + border-color: ${C.accent}; + box-shadow: 0 0 24px rgba(255, 107, 53, 0.12); + } + &:hover:not(:focus) { + border-color: #3d3d54; + } + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; diff --git a/app/components/styled/musicVisual.js b/app/components/styled/musicVisual.js new file mode 100644 index 0000000..9f92cb3 --- /dev/null +++ b/app/components/styled/musicVisual.js @@ -0,0 +1,131 @@ +import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; +import { C } from './colors'; + +const spin = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +const pulse = keyframes` + 0%, 100% { transform: scale(1); opacity: 0.15; } + 50% { transform: scale(1.08); opacity: 0.35; } +`; + +const equalize = keyframes` + 0%, 100% { height: 15%; } + 25% { height: 55%; } + 50% { height: 85%; } + 75% { height: 35%; } +`; + +const floatUp = keyframes` + 0% { transform: translateY(0) rotate(0deg); opacity: 0.7; } + 100% { transform: translateY(-280px) rotate(40deg); opacity: 0; } +`; + +export const VinylRecord = styled.div` + width: 260px; + height: 260px; + border-radius: 50%; + background: radial-gradient( + circle, + #1a1a2e 0%, + #0d0d0d 20%, + #2d1b4e 21%, + #0d0d0d 40%, + #2d1b4e 41%, + #0d0d0d 60%, + #2d1b4e 61%, + #0d0d0d 80%, + #1a1a2e 100% + ); + animation: ${spin} 8s linear infinite; + position: relative; + z-index: 2; + box-shadow: 0 0 60px rgba(255, 107, 53, 0.12); + cursor: pointer; + transition: box-shadow 0.4s ease; + &:hover { + animation-duration: 2s; + box-shadow: 0 0 90px rgba(232, 67, 147, 0.25); + } + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 55px; + height: 55px; + border-radius: 50%; + background: radial-gradient(circle, #ff6b35 0%, #e84393 100%); + box-shadow: 0 0 25px rgba(255, 107, 53, 0.5); + } +`; + +export const GlowRing = styled.div` + position: absolute; + width: ${(props) => props.size}px; + height: ${(props) => props.size}px; + border-radius: 50%; + border: 1px solid rgba(255, 107, 53, 0.08); + animation: ${pulse} ${(props) => props.duration}s ease-in-out infinite; + animation-delay: ${(props) => props.delay}s; +`; + +export const EqualizerWrapper = styled.div` + display: flex; + align-items: flex-end; + gap: 5px; + height: 100px; + position: absolute; + bottom: 70px; + z-index: 1; +`; + +export const EqualizerBar = styled.div` + width: 4px; + height: 15%; + background: linear-gradient(to top, #ff6b35, #e84393); + border-radius: 2px; + animation: ${equalize} ${(props) => props.duration}s ease-in-out infinite; + animation-delay: ${(props) => props.delay}s; + opacity: 0.6; + transition: opacity 0.3s ease; + &:hover { + opacity: 1; + } +`; + +export const FloatingNote = styled.div` + position: absolute; + font-size: ${(props) => props.size}px; + color: rgba(255, 107, 53, 0.35); + animation: ${floatUp} ${(props) => props.duration}s ease-out infinite; + animation-delay: ${(props) => props.delay}s; + left: ${(props) => props.left}%; + bottom: ${(props) => props.bottom}%; + user-select: none; +`; + +export const BrandText = styled.h1` + font-family: 'Syne', sans-serif; + font-size: 3.2rem; + font-weight: 800; + letter-spacing: 0.3em; + color: ${C.text}; + margin-bottom: 0.5rem; + z-index: 3; + text-shadow: 0 0 40px rgba(255, 107, 53, 0.25); +`; + +export const TaglineText = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.95rem; + color: ${C.muted}; + letter-spacing: 0.25em; + z-index: 3; + margin-top: 0; + margin-bottom: 2.5rem; +`; diff --git a/app/components/styled/navLink.js b/app/components/styled/navLink.js new file mode 100644 index 0000000..bc82da3 --- /dev/null +++ b/app/components/styled/navLink.js @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const NavLinkStyled = styled.a` + font-family: 'Syne', sans-serif; + font-size: 0.9rem; + font-weight: ${(p) => (p.isActive ? '700' : '500')}; + color: ${(p) => (p.isActive ? C.accent : C.muted)}; + text-decoration: none; + padding: 0.4rem 0.8rem; + border-bottom: 2px solid ${(p) => (p.isActive ? C.accent : 'transparent')}; + transition: all 0.25s ease; + &:hover { + color: ${C.accent}; + } +`; + +export const NavGroup = styled.nav` + display: flex; + gap: 0.25rem; + margin-left: 1.5rem; +`; diff --git a/app/components/styled/playerBar.js b/app/components/styled/playerBar.js new file mode 100644 index 0000000..528c75f --- /dev/null +++ b/app/components/styled/playerBar.js @@ -0,0 +1,122 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const PlayerContainer = styled.div` + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 88px; + background: var(--musica-cardBg); + backdrop-filter: blur(16px); + border-top: 1px solid ${C.border}; + display: flex; + align-items: center; + padding: 0 1.5rem; + gap: 1.25rem; + z-index: 100; +`; + +export const NowPlayingArt = styled.img` + width: 52px; + height: 52px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +`; + +export const PlayerTrackInfo = styled.div` + display: flex; + flex-direction: column; + min-width: 0; + width: 180px; + flex-shrink: 0; +`; + +export const TrackTitle = styled.span` + font-family: 'Syne', sans-serif; + font-size: 0.9rem; + font-weight: 600; + color: ${C.text}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const TrackArtist = styled.span` + font-family: 'Outfit', sans-serif; + font-size: 0.78rem; + color: ${C.muted}; +`; + +export const PlayerControls = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; + flex: 1; + justify-content: center; +`; + +export const ControlButton = styled.button` + background: none; + border: none; + color: ${C.text}; + font-size: ${(p) => (p.primary ? '1.6rem' : '1.1rem')}; + cursor: pointer; + padding: 0.5rem 0.75rem; + border-radius: 8px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + background: ${(p) => (p.primary ? `linear-gradient(135deg, ${C.accent}, ${C.pink})` : 'transparent')}; + &:hover { + transform: scale(1.15); + } + &:active { + transform: scale(0.94); + } +`; + +export const ProgressSlider = styled.input` + flex: 1; + max-width: 300px; + height: 4px; + appearance: none; + background: linear-gradient(to right, ${C.accent} var(--fill, 0%), ${C.border} var(--fill, 0%)); + border-radius: 2px; + outline: none; + &::-webkit-slider-thumb { + appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: ${C.accent}; + cursor: pointer; + } +`; + +export const VolumeGroup = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +`; + +export const VolumeSlider = styled.input` + width: 90px; + height: 4px; + appearance: none; + background: linear-gradient(to right, ${C.accent} var(--fill, 0%), ${C.border} var(--fill, 0%)); + border-radius: 2px; + outline: none; + flex-shrink: 0; + &::-webkit-slider-thumb { + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: ${C.accent}; + cursor: pointer; + } +`; diff --git a/app/components/styled/songList.js b/app/components/styled/songList.js new file mode 100644 index 0000000..7d46671 --- /dev/null +++ b/app/components/styled/songList.js @@ -0,0 +1,66 @@ +import styled from '@emotion/styled'; +import { keyframes } from '@emotion/react'; +import { C } from './colors'; + +const glowPulse = keyframes` + 0%, 100% { box-shadow: 0 0 8px rgba(255, 107, 53, 0.15); } + 50% { box-shadow: 0 0 18px rgba(255, 107, 53, 0.3); } +`; + +export const SongListWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +export const SongCard = styled.div` + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1rem; + background: ${(p) => (p.isActive ? C.surface : C.cardBg)}; + border-radius: 10px; + border-left: 3px solid ${(p) => (p.isActive ? C.accent : 'transparent')}; + cursor: pointer; + transition: all 0.25s ease; + animation: ${(p) => (p.isActive ? glowPulse : 'none')} 2s ease-in-out infinite; + &:hover { + transform: translateX(4px); + background: ${C.surface}; + } +`; + +export const SongInfo = styled.div` + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +`; + +export const SongTitle = styled.span` + font-family: 'Syne', sans-serif; + font-size: 0.95rem; + font-weight: 600; + color: ${C.text}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const SongArtist = styled.span` + font-family: 'Outfit', sans-serif; + font-size: 0.8rem; + color: ${C.muted}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const SongAlbum = styled.span` + font-family: 'Outfit', sans-serif; + font-size: 0.75rem; + color: ${C.placeholder}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; diff --git a/app/components/styled/themeToggle.js b/app/components/styled/themeToggle.js new file mode 100644 index 0000000..fcacc48 --- /dev/null +++ b/app/components/styled/themeToggle.js @@ -0,0 +1,26 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const ToggleButton = styled.button` + background: ${C.cardBg}; + border: 1px solid ${C.border}; + color: ${C.text}; + width: 38px; + height: 38px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + cursor: pointer; + transition: all 0.3s ease; + flex-shrink: 0; + &:hover { + transform: scale(1.1); + border-color: ${C.accent}; + box-shadow: 0 0 16px rgba(255, 107, 53, 0.2); + } + &:active { + transform: scale(0.95); + } +`; diff --git a/app/components/styled/trackInfo.js b/app/components/styled/trackInfo.js new file mode 100644 index 0000000..a187ed0 --- /dev/null +++ b/app/components/styled/trackInfo.js @@ -0,0 +1,79 @@ +import styled from '@emotion/styled'; +import { C } from './colors'; + +export const TrackDetailWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + padding: 2rem 0; +`; + +export const TrackArtworkLarge = styled.img` + width: 250px; + height: 250px; + border-radius: 16px; + object-fit: cover; + box-shadow: 0 8px 32px rgba(255, 107, 53, 0.2); +`; + +export const TrackMeta = styled.div` + text-align: center; + display: flex; + flex-direction: column; + gap: 0.35rem; +`; + +export const TrackDetailName = styled.h2` + font-family: 'Syne', sans-serif; + font-size: 1.6rem; + font-weight: 700; + color: ${C.text}; + margin: 0; +`; + +export const TrackDetailArtist = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 1rem; + color: ${C.muted}; + margin: 0; +`; + +export const TrackDetailAlbum = styled.p` + font-family: 'Outfit', sans-serif; + font-size: 0.85rem; + color: ${C.placeholder}; + margin: 0; +`; + +export const TagRow = styled.div` + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + justify-content: center; +`; + +export const Tag = styled.span` + font-family: 'Outfit', sans-serif; + font-size: 0.78rem; + padding: 0.3rem 0.75rem; + border-radius: 20px; + background: ${C.surface}; + color: ${C.label}; +`; + +export const StoreLink = styled.a` + font-family: 'Syne', sans-serif; + font-size: 0.85rem; + font-weight: 600; + color: ${C.accent}; + text-decoration: none; + padding: 0.5rem 1.25rem; + border: 1px solid ${C.accent}; + border-radius: 8px; + transition: all 0.2s ease; + &:hover { + background: ${C.accent}; + color: #fff; + } +`; diff --git a/app/containers/Auth/LoginForm.js b/app/containers/Auth/LoginForm.js new file mode 100644 index 0000000..7daf25a --- /dev/null +++ b/app/containers/Auth/LoginForm.js @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import { + FormCard, + FormTitle, + FormSubtitle, + InputWrapper, + InputLabel, + StyledInput, + SubmitButton, + SwitchText, + SwitchLink, + ErrorMessage +} from '@components/styled/authForm'; + +const LoginForm = ({ onSubmit, loading, error }) => { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(email, password); + }; + + return ( + + Welcome back + Sign in to continue your musical journey +
+ + Email + setEmail(e.target.value)} + required + /> + + + Password + setPassword(e.target.value)} + required + /> + + {error && {error}} + + {loading ? 'Signing in...' : 'Sign In'} + +
+ + Don't have an account?{' '} + + Create one + + +
+ ); +}; + +LoginForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + loading: PropTypes.bool, + error: PropTypes.string +}; + +export default LoginForm; diff --git a/app/containers/Auth/SignupForm.js b/app/containers/Auth/SignupForm.js new file mode 100644 index 0000000..b5d9188 --- /dev/null +++ b/app/containers/Auth/SignupForm.js @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import Link from 'next/link'; +import PropTypes from 'prop-types'; +import { + FormCard, + FormTitle, + FormSubtitle, + InputWrapper, + InputLabel, + StyledInput, + SubmitButton, + SwitchText, + SwitchLink, + ErrorMessage +} from '@components/styled/authForm'; + +const SignupForm = ({ onSubmit, loading, error }) => { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(name, email, password); + }; + + return ( + + Join the beat + Create your account and start listening +
+ + Full Name + setName(e.target.value)} + required + /> + + + Email + setEmail(e.target.value)} + required + /> + + + Password + setPassword(e.target.value)} + required + /> + + {error && {error}} + + {loading ? 'Creating account...' : 'Create Account'} + +
+ + Already have an account?{' '} + + Sign in + + +
+ ); +}; + +SignupForm.propTypes = { + onSubmit: PropTypes.func.isRequired, + loading: PropTypes.bool, + error: PropTypes.string +}; + +export default SignupForm; diff --git a/app/containers/Auth/reducer.js b/app/containers/Auth/reducer.js new file mode 100644 index 0000000..711084b --- /dev/null +++ b/app/containers/Auth/reducer.js @@ -0,0 +1,48 @@ +import { PAYLOAD, startLoading, stopLoading, setError, setData } from '@app/utils/reducer'; +import produce from 'immer'; +import { createActions } from 'reduxsauce'; + +export const initialState = { + [PAYLOAD.DATA]: null, + [PAYLOAD.ERROR]: null, + [PAYLOAD.LOADING]: false +}; + +export const { Types: authTypes, Creators: authCreators } = createActions({ + requestLogin: ['email', 'password'], + successAuth: [PAYLOAD.DATA], + failureAuth: [PAYLOAD.ERROR], + requestSignup: ['name', 'email', 'password'], + clearAuth: null +}); + +const handleRequest = (draft) => { + startLoading(draft); + draft[PAYLOAD.ERROR] = null; +}; + +const handleSuccess = (draft, action) => { + stopLoading(draft); + setData(draft, action); +}; + +const handleFailure = (draft, action) => { + stopLoading(draft); + setError(draft, action); +}; + +const handlers = { + [authTypes.REQUEST_LOGIN]: handleRequest, + [authTypes.REQUEST_SIGNUP]: handleRequest, + [authTypes.SUCCESS_AUTH]: handleSuccess, + [authTypes.FAILURE_AUTH]: handleFailure, + [authTypes.CLEAR_AUTH]: () => initialState +}; + +export const authReducer = (state = initialState, action) => + produce(state, (draft) => { + const handler = handlers[action.type]; + return handler ? handler(draft, action) : state; + }); + +export default authReducer; diff --git a/app/containers/Auth/saga.js b/app/containers/Auth/saga.js new file mode 100644 index 0000000..1c22ecd --- /dev/null +++ b/app/containers/Auth/saga.js @@ -0,0 +1,44 @@ +import { call, put, takeLatest } from 'redux-saga/effects'; +import Router from 'next/router'; +import { loginUser, signupUser } from '@services/authApi'; +import { setStoredToken } from '@utils/authStorage'; +import { setAuthHeader } from '@utils/apiUtils'; +import { authTypes, authCreators } from './reducer'; + +const { successAuth, failureAuth } = authCreators; + +const persistToken = (data) => { + if (data && data.accessToken) { + setStoredToken(data.accessToken); + setAuthHeader('music', data.accessToken); + } +}; + +export function* handleLogin(action) { + const { email, password } = action; + const response = yield call(loginUser, { email, password }); + if (response.ok) { + yield put(successAuth(response.data)); + persistToken(response.data); + Router.push('/'); + } else { + yield put(failureAuth(response.data)); + } +} + +export function* handleSignup(action) { + const { name, email, password } = action; + const response = yield call(signupUser, { name, email, password }); + if (response.ok) { + yield put(successAuth(response.data)); + persistToken(response.data); + Router.push('/'); + } else { + yield put(failureAuth(response.data)); + } +} + +export default function* authSaga() { + yield takeLatest(authTypes.REQUEST_LOGIN, handleLogin); + yield takeLatest(authTypes.REQUEST_SIGNUP, handleSignup); +} diff --git a/app/containers/Auth/selectors.js b/app/containers/Auth/selectors.js new file mode 100644 index 0000000..18fef2b --- /dev/null +++ b/app/containers/Auth/selectors.js @@ -0,0 +1,13 @@ +import { PAYLOAD } from '@app/utils/reducer'; +import get from 'lodash/get'; +import { createSelector } from 'reselect'; +import { initialState } from './reducer'; + +const selectAuthDomain = (state) => state.auth || initialState; + +export const selectAuthData = () => createSelector(selectAuthDomain, (substate) => get(substate, PAYLOAD.DATA, null)); + +export const selectAuthError = () => createSelector(selectAuthDomain, (substate) => get(substate, PAYLOAD.ERROR, null)); + +export const selectAuthLoading = () => + createSelector(selectAuthDomain, (substate) => get(substate, PAYLOAD.LOADING, false)); diff --git a/app/containers/Auth/tests/LoginForm.test.js b/app/containers/Auth/tests/LoginForm.test.js new file mode 100644 index 0000000..e616174 --- /dev/null +++ b/app/containers/Auth/tests/LoginForm.test.js @@ -0,0 +1,54 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import LoginForm from '../LoginForm' + +describe('', () => { + const mockSubmit = jest.fn() + const defaultProps = { onSubmit: mockSubmit, loading: false, error: null } + + beforeEach(() => { + mockSubmit.mockClear() + }) + + it('should render and match the snapshot', () => { + const { baseElement } = renderProvider() + expect(baseElement).toMatchSnapshot() + }) + + it('should render email and password inputs', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('login-email')).toBeTruthy() + expect(getByTestId('login-password')).toBeTruthy() + }) + + it('should call onSubmit with email and password', () => { + const { getByTestId } = renderProvider() + fireEvent.change(getByTestId('login-email'), { + target: { value: 'test@test.com' } + }) + fireEvent.change(getByTestId('login-password'), { + target: { value: 'pass123' } + }) + fireEvent.click(getByTestId('login-submit')) + expect(mockSubmit).toHaveBeenCalledWith('test@test.com', 'pass123') + }) + + it('should display error message when error prop is set', () => { + const props = { ...defaultProps, error: 'Invalid credentials' } + const { getByTestId } = renderProvider() + expect(getByTestId('login-error').textContent).toBe('Invalid credentials') + }) + + it('should disable submit button when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('login-submit')).toBeDisabled() + }) + + it('should show loading text when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('login-submit').textContent).toBe('Signing in...') + }) +}) diff --git a/app/containers/Auth/tests/SignupForm.test.js b/app/containers/Auth/tests/SignupForm.test.js new file mode 100644 index 0000000..1bb1948 --- /dev/null +++ b/app/containers/Auth/tests/SignupForm.test.js @@ -0,0 +1,62 @@ +import React from 'react' +import { fireEvent } from '@testing-library/react' +import { renderProvider } from '@utils/testUtils' +import SignupForm from '../SignupForm' + +describe('', () => { + const mockSubmit = jest.fn() + const defaultProps = { onSubmit: mockSubmit, loading: false, error: null } + + beforeEach(() => { + mockSubmit.mockClear() + }) + + it('should render and match the snapshot', () => { + const { baseElement } = renderProvider() + expect(baseElement).toMatchSnapshot() + }) + + it('should render name, email, and password inputs', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('signup-name')).toBeTruthy() + expect(getByTestId('signup-email')).toBeTruthy() + expect(getByTestId('signup-password')).toBeTruthy() + }) + + it('should call onSubmit with name, email, and password', () => { + const { getByTestId } = renderProvider() + fireEvent.change(getByTestId('signup-name'), { + target: { value: 'Test User' } + }) + fireEvent.change(getByTestId('signup-email'), { + target: { value: 'test@test.com' } + }) + fireEvent.change(getByTestId('signup-password'), { + target: { value: 'pass123' } + }) + fireEvent.click(getByTestId('signup-submit')) + expect(mockSubmit).toHaveBeenCalledWith( + 'Test User', + 'test@test.com', + 'pass123' + ) + }) + + it('should display error message when error prop is set', () => { + const props = { ...defaultProps, error: 'Email already exists' } + const { getByTestId } = renderProvider() + expect(getByTestId('signup-error').textContent).toBe('Email already exists') + }) + + it('should disable submit button when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('signup-submit')).toBeDisabled() + }) + + it('should show loading text when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('signup-submit').textContent).toBe('Creating account...') + }) +}) diff --git a/app/containers/Auth/tests/__snapshots__/LoginForm.test.js.snap b/app/containers/Auth/tests/__snapshots__/LoginForm.test.js.snap new file mode 100644 index 0000000..c67ec3f --- /dev/null +++ b/app/containers/Auth/tests/__snapshots__/LoginForm.test.js.snap @@ -0,0 +1,222 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +.emotion-0 { + width: 100%; + max-width: 420px; + padding: 3rem; + background: var(--musica-bg); + border-radius: 16px; + border: 1px solid var(--musica-border); +} + +.emotion-1 { + font-family: 'Syne',sans-serif; + font-size: 2rem; + font-weight: 700; + color: var(--musica-text); + margin-bottom: 0.5rem; +} + +.emotion-2 { + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + color: var(--musica-muted); + margin-bottom: 2rem; +} + +.emotion-3 { + margin-bottom: 1.25rem; +} + +.emotion-4 { + font-family: 'Outfit',sans-serif; + font-size: 0.85rem; + color: var(--musica-label); + display: block; + margin-bottom: 0.5rem; +} + +.emotion-5 { + width: 100%; + padding: 0.85rem 1rem; + background: var(--musica-inputBg); + border: 1.5px solid var(--musica-border); + border-radius: 10px; + color: var(--musica-text); + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + outline: none; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.emotion-5::-webkit-input-placeholder { + color: var(--musica-placeholder); +} + +.emotion-5::-moz-placeholder { + color: var(--musica-placeholder); +} + +.emotion-5:-ms-input-placeholder { + color: var(--musica-placeholder); +} + +.emotion-5::placeholder { + color: var(--musica-placeholder); +} + +.emotion-5:focus { + border-color: var(--musica-accent); + box-shadow: 0 0 20px rgba(255, 107, 53, 0.15); +} + +.emotion-5:hover:not(:focus) { + border-color: #3d3d54; +} + +.emotion-9 { + width: 100%; + padding: 0.9rem; + background: linear-gradient(135deg, var(--musica-accent), var(--musica-pink)); + border: none; + border-radius: 10px; + color: var(--musica-text); + font-family: 'Syne',sans-serif; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + margin-top: 0.5rem; + letter-spacing: 0.05em; +} + +.emotion-9:hover { + -webkit-transform: translateY(-2px); + -moz-transform: translateY(-2px); + -ms-transform: translateY(-2px); + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(255, 107, 53, 0.3); +} + +.emotion-9:active { + -webkit-transform: translateY(0); + -moz-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); +} + +.emotion-9:disabled { + opacity: 0.6; + cursor: not-allowed; + -webkit-transform: none; + -moz-transform: none; + -ms-transform: none; + transform: none; +} + +.emotion-10 { + font-family: 'Outfit',sans-serif; + font-size: 0.9rem; + color: var(--musica-muted); + text-align: center; + margin-top: 1.5rem; +} + +.emotion-11 { + color: var(--musica-accent); + -webkit-text-decoration: none; + text-decoration: none; + font-weight: 500; + cursor: pointer; + -webkit-transition: color 0.2s ease; + transition: color 0.2s ease; +} + +.emotion-11:hover { + color: var(--musica-pink); + -webkit-text-decoration: underline; + text-decoration: underline; +} + + +
+
+

+ Welcome back +

+

+ Sign in to continue your musical journey +

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

+ Don't have an account? + + + Create one + +

+
+
+ +`; diff --git a/app/containers/Auth/tests/__snapshots__/SignupForm.test.js.snap b/app/containers/Auth/tests/__snapshots__/SignupForm.test.js.snap new file mode 100644 index 0000000..401cc47 --- /dev/null +++ b/app/containers/Auth/tests/__snapshots__/SignupForm.test.js.snap @@ -0,0 +1,241 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render and match the snapshot 1`] = ` +.emotion-0 { + width: 100%; + max-width: 420px; + padding: 3rem; + background: var(--musica-bg); + border-radius: 16px; + border: 1px solid var(--musica-border); +} + +.emotion-1 { + font-family: 'Syne',sans-serif; + font-size: 2rem; + font-weight: 700; + color: var(--musica-text); + margin-bottom: 0.5rem; +} + +.emotion-2 { + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + color: var(--musica-muted); + margin-bottom: 2rem; +} + +.emotion-3 { + margin-bottom: 1.25rem; +} + +.emotion-4 { + font-family: 'Outfit',sans-serif; + font-size: 0.85rem; + color: var(--musica-label); + display: block; + margin-bottom: 0.5rem; +} + +.emotion-5 { + width: 100%; + padding: 0.85rem 1rem; + background: var(--musica-inputBg); + border: 1.5px solid var(--musica-border); + border-radius: 10px; + color: var(--musica-text); + font-family: 'Outfit',sans-serif; + font-size: 0.95rem; + outline: none; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + box-sizing: border-box; +} + +.emotion-5::-webkit-input-placeholder { + color: var(--musica-placeholder); +} + +.emotion-5::-moz-placeholder { + color: var(--musica-placeholder); +} + +.emotion-5:-ms-input-placeholder { + color: var(--musica-placeholder); +} + +.emotion-5::placeholder { + color: var(--musica-placeholder); +} + +.emotion-5:focus { + border-color: var(--musica-accent); + box-shadow: 0 0 20px rgba(255, 107, 53, 0.15); +} + +.emotion-5:hover:not(:focus) { + border-color: #3d3d54; +} + +.emotion-12 { + width: 100%; + padding: 0.9rem; + background: linear-gradient(135deg, var(--musica-accent), var(--musica-pink)); + border: none; + border-radius: 10px; + color: var(--musica-text); + font-family: 'Syne',sans-serif; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + -webkit-transition: all 0.3s ease; + transition: all 0.3s ease; + margin-top: 0.5rem; + letter-spacing: 0.05em; +} + +.emotion-12:hover { + -webkit-transform: translateY(-2px); + -moz-transform: translateY(-2px); + -ms-transform: translateY(-2px); + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(255, 107, 53, 0.3); +} + +.emotion-12:active { + -webkit-transform: translateY(0); + -moz-transform: translateY(0); + -ms-transform: translateY(0); + transform: translateY(0); +} + +.emotion-12:disabled { + opacity: 0.6; + cursor: not-allowed; + -webkit-transform: none; + -moz-transform: none; + -ms-transform: none; + transform: none; +} + +.emotion-13 { + font-family: 'Outfit',sans-serif; + font-size: 0.9rem; + color: var(--musica-muted); + text-align: center; + margin-top: 1.5rem; +} + +.emotion-14 { + color: var(--musica-accent); + -webkit-text-decoration: none; + text-decoration: none; + font-weight: 500; + cursor: pointer; + -webkit-transition: color 0.2s ease; + transition: color 0.2s ease; +} + +.emotion-14:hover { + color: var(--musica-pink); + -webkit-text-decoration: underline; + text-decoration: underline; +} + + +
+
+

+ Join the beat +

+

+ Create your account and start listening +

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

+ Already have an account? + + + Sign in + +

+
+
+ +`; diff --git a/app/containers/Auth/tests/reducer.test.js b/app/containers/Auth/tests/reducer.test.js new file mode 100644 index 0000000..d6b5ce7 --- /dev/null +++ b/app/containers/Auth/tests/reducer.test.js @@ -0,0 +1,62 @@ +import { PAYLOAD } from '@app/utils/reducer' +import { authReducer, initialState, authTypes } from '../reducer' + +describe('Auth reducer tests', () => { + let state + beforeEach(() => { + state = initialState + }) + + it('should return the initial state', () => { + expect(authReducer(undefined, {})).toEqual(state) + }) + + it('should set loading to true when REQUEST_LOGIN is dispatched', () => { + const expectedResult = { ...state, loading: true, error: null } + expect( + authReducer(state, { + type: authTypes.REQUEST_LOGIN, + email: 'test@test.com', + password: 'password123' + }) + ).toEqual(expectedResult) + }) + + it('should set loading to true when REQUEST_SIGNUP is dispatched', () => { + const expectedResult = { ...state, loading: true, error: null } + expect( + authReducer(state, { + type: authTypes.REQUEST_SIGNUP, + name: 'Test User', + email: 'test@test.com', + password: 'password123' + }) + ).toEqual(expectedResult) + }) + + it('should set data when SUCCESS_AUTH is dispatched', () => { + const data = { token: 'abc123', user: { name: 'Test' } } + const expectedResult = { ...state, data, loading: false } + expect(authReducer(state, { type: authTypes.SUCCESS_AUTH, data })).toEqual( + expectedResult + ) + }) + + it('should set error when FAILURE_AUTH is dispatched', () => { + const expectedResult = { + ...state, + [PAYLOAD.ERROR]: 'something_went_wrong', + loading: false + } + expect(authReducer(state, { type: authTypes.FAILURE_AUTH })).toEqual( + expectedResult + ) + }) + + it('should reset state when CLEAR_AUTH is dispatched', () => { + const modifiedState = { ...state, data: { token: 'abc' }, loading: true } + expect(authReducer(modifiedState, { type: authTypes.CLEAR_AUTH })).toEqual( + initialState + ) + }) +}) diff --git a/app/containers/Auth/tests/saga.test.js b/app/containers/Auth/tests/saga.test.js new file mode 100644 index 0000000..1dff682 --- /dev/null +++ b/app/containers/Auth/tests/saga.test.js @@ -0,0 +1,75 @@ +import { takeLatest, call, put } from 'redux-saga/effects' +import { loginUser, signupUser } from '@services/authApi' +import { apiResponseGenerator } from '@utils/testUtils' +import authSaga, { handleLogin, handleSignup } from '../saga' +import { authTypes } from '../reducer' + +describe('Auth saga tests', () => { + const generator = authSaga() + + it('should watch for REQUEST_LOGIN action', () => { + expect(generator.next().value).toEqual( + takeLatest(authTypes.REQUEST_LOGIN, handleLogin) + ) + }) + + it('should watch for REQUEST_SIGNUP action', () => { + expect(generator.next().value).toEqual( + takeLatest(authTypes.REQUEST_SIGNUP, handleSignup) + ) + }) + + describe('handleLogin', () => { + const action = { email: 'test@test.com', password: 'pass123' } + + it('should dispatch SUCCESS_AUTH on successful login', () => { + const gen = handleLogin(action) + const res = gen.next().value + expect(res).toEqual( + call(loginUser, { email: action.email, password: action.password }) + ) + const successData = { token: 'abc123' } + expect(gen.next(apiResponseGenerator(true, successData)).value).toEqual( + put({ type: authTypes.SUCCESS_AUTH, data: successData }) + ) + }) + + it('should dispatch FAILURE_AUTH on failed login', () => { + const gen = handleLogin(action) + gen.next() + const errorData = { message: 'Invalid credentials' } + expect(gen.next(apiResponseGenerator(false, errorData)).value).toEqual( + put({ type: authTypes.FAILURE_AUTH, error: errorData }) + ) + }) + }) + + describe('handleSignup', () => { + const action = { name: 'Test', email: 'test@test.com', password: 'pass123' } + + it('should dispatch SUCCESS_AUTH on successful signup', () => { + const gen = handleSignup(action) + const res = gen.next().value + expect(res).toEqual( + call(signupUser, { + name: action.name, + email: action.email, + password: action.password + }) + ) + const successData = { token: 'abc123' } + expect(gen.next(apiResponseGenerator(true, successData)).value).toEqual( + put({ type: authTypes.SUCCESS_AUTH, data: successData }) + ) + }) + + it('should dispatch FAILURE_AUTH on failed signup', () => { + const gen = handleSignup(action) + gen.next() + const errorData = { message: 'Email already exists' } + expect(gen.next(apiResponseGenerator(false, errorData)).value).toEqual( + put({ type: authTypes.FAILURE_AUTH, error: errorData }) + ) + }) + }) +}) diff --git a/app/containers/Auth/tests/selectors.test.js b/app/containers/Auth/tests/selectors.test.js new file mode 100644 index 0000000..b2d095d --- /dev/null +++ b/app/containers/Auth/tests/selectors.test.js @@ -0,0 +1,46 @@ +import { + selectAuthData, + selectAuthError, + selectAuthLoading +} from '../selectors' + +describe('Auth selector tests', () => { + let mockedState + let authData + let authError + + beforeEach(() => { + authData = { token: 'abc123', user: { name: 'Test' } } + authError = 'Invalid credentials' + + mockedState = { + auth: { + data: authData, + error: authError, + loading: true + } + } + }) + + it('should select auth data', () => { + const dataSelector = selectAuthData() + expect(dataSelector(mockedState)).toEqual(authData) + }) + + it('should select auth error', () => { + const errorSelector = selectAuthError() + expect(errorSelector(mockedState)).toEqual(authError) + }) + + it('should select auth loading', () => { + const loadingSelector = selectAuthLoading() + expect(loadingSelector(mockedState)).toEqual(true) + }) + + it('should return defaults when auth state is empty', () => { + const emptyState = {} + expect(selectAuthData()(emptyState)).toBeNull() + expect(selectAuthError()(emptyState)).toBeNull() + expect(selectAuthLoading()(emptyState)).toBe(false) + }) +}) diff --git a/app/containers/Library/constants.js b/app/containers/Library/constants.js new file mode 100644 index 0000000..74db322 --- /dev/null +++ b/app/containers/Library/constants.js @@ -0,0 +1,6 @@ +export const LIBRARY_PAYLOAD = { + LIKED_SONGS: 'likedSongs', + LIKED_TRACK_IDS: 'likedTrackIds', + TRACK_ID: 'trackId', + SONG_DATA: 'songData' +}; diff --git a/app/containers/Library/index.js b/app/containers/Library/index.js new file mode 100644 index 0000000..7ef5575 --- /dev/null +++ b/app/containers/Library/index.js @@ -0,0 +1,126 @@ +import React, { useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import { createStructuredSelector } from 'reselect'; +import injectSaga from '@utils/injectSaga'; +import SongList from '@components/SongList'; +import AudioPlayer from '@components/AudioPlayer'; +import If from '@components/If'; +import ThemeToggle from '@components/ThemeToggle'; +import LogoutButton from '@components/LogoutButton'; +import NavLink from '@components/NavLink'; +import { + MusicPageWrapper, + MusicPageContent, + PageHeader, + PageTitle, + HeaderActions, + EmptyState, + LoadingSpinner +} from '@components/styled/musicPage'; +import { NavGroup } from '@components/styled/navLink'; +import { musicCreators } from '@app/containers/Music/reducer'; +import { libraryCreators } from './reducer'; +import saga from './saga'; +import musicSaga from '@app/containers/Music/saga'; +import { selectLikedSongs, selectLikedTrackIds, selectLibraryLoading } from './selectors'; +import { selectCurrentSong, selectIsPlaying } from '@app/containers/Music/selectors'; +import usePlaybackNav from '@app/containers/Music/usePlaybackNav'; +import useToggleLike from '@app/containers/Music/useToggleLike'; +import usePlayToggle from '@app/containers/Music/usePlayToggle'; + +export function Library(props) { + const { likedSongs, likedTrackIds, loading, currentSong, isPlaying } = props; + const { dispatchFetchLibrary, dispatchSetSong, dispatchLike, dispatchUnlike, dispatchSetIsPlaying } = props; + + useEffect(() => { + dispatchFetchLibrary(); + }, []); + + const { handleNext, handlePrev } = usePlaybackNav({ songs: likedSongs, currentSong, dispatchSetSong }); + const handleToggleLike = useToggleLike({ likedTrackIds, dispatchLike, dispatchUnlike }); + const { handlePlayToggle, registerTogglePlay } = usePlayToggle({ currentSong, dispatchSetSong }); + + return ( + + + + MUSICA + + + + + + + + + + + + + 0}> + + + + + No liked songs yet. Search and like songs to build your library. + + + + + + ); +} + +Library.propTypes = { + likedSongs: PropTypes.array, + likedTrackIds: PropTypes.object, + loading: PropTypes.bool, + currentSong: PropTypes.object, + isPlaying: PropTypes.bool, + dispatchFetchLibrary: PropTypes.func.isRequired, + dispatchSetSong: PropTypes.func.isRequired, + dispatchLike: PropTypes.func.isRequired, + dispatchUnlike: PropTypes.func.isRequired, + dispatchSetIsPlaying: PropTypes.func.isRequired +}; + +const mapStateToProps = createStructuredSelector({ + likedSongs: selectLikedSongs(), + likedTrackIds: selectLikedTrackIds(), + loading: selectLibraryLoading(), + currentSong: selectCurrentSong(), + isPlaying: selectIsPlaying() +}); + +function mapDispatchToProps(dispatch) { + return { + dispatchFetchLibrary: () => dispatch(libraryCreators.requestFetchLibrary()), + dispatchSetSong: (song) => dispatch(musicCreators.setCurrentSong(song)), + dispatchSetIsPlaying: (val) => dispatch(musicCreators.setIsPlaying(val)), + dispatchLike: (song) => dispatch(libraryCreators.requestLikeSong(song)), + dispatchUnlike: (id) => dispatch(libraryCreators.requestUnlikeSong(id)) + }; +} + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + injectSaga({ key: 'library', saga }), + injectSaga({ key: 'music', saga: musicSaga }) +)(Library); + +export const LibraryTest = Library; diff --git a/app/containers/Library/reducer.js b/app/containers/Library/reducer.js new file mode 100644 index 0000000..71444f2 --- /dev/null +++ b/app/containers/Library/reducer.js @@ -0,0 +1,80 @@ +import { PAYLOAD, startLoading, stopLoading, setError } from '@app/utils/reducer'; +import produce from 'immer'; +import { createActions } from 'reduxsauce'; +import { LIBRARY_PAYLOAD } from './constants'; + +export const initialState = { + [LIBRARY_PAYLOAD.LIKED_SONGS]: [], + [LIBRARY_PAYLOAD.LIKED_TRACK_IDS]: {}, + [PAYLOAD.ERROR]: null, + [PAYLOAD.LOADING]: false +}; + +export const { Types: libraryTypes, Creators: libraryCreators } = createActions({ + requestFetchLibrary: null, + successFetchLibrary: [PAYLOAD.DATA], + failureFetchLibrary: [PAYLOAD.ERROR], + requestLikeSong: [LIBRARY_PAYLOAD.SONG_DATA], + successLikeSong: [LIBRARY_PAYLOAD.SONG_DATA], + failureLikeSong: [PAYLOAD.ERROR], + requestUnlikeSong: [LIBRARY_PAYLOAD.TRACK_ID], + successUnlikeSong: [LIBRARY_PAYLOAD.TRACK_ID], + failureUnlikeSong: [PAYLOAD.ERROR], + clearLibrary: null +}); + +const buildTrackIdMap = (songs) => { + const map = {}; + songs.forEach((s) => { + map[s.trackId] = true; + }); + return map; +}; + +const handleFetchRequest = (draft) => { + startLoading(draft); + draft[PAYLOAD.ERROR] = null; +}; + +const handleFetchSuccess = (draft, action) => { + stopLoading(draft); + const songs = action[PAYLOAD.DATA] || []; + draft[LIBRARY_PAYLOAD.LIKED_SONGS] = songs; + draft[LIBRARY_PAYLOAD.LIKED_TRACK_IDS] = buildTrackIdMap(songs); +}; + +const handleFetchFailure = (draft, action) => { + stopLoading(draft); + setError(draft, action); +}; + +const handleLikeSuccess = (draft, action) => { + const song = action[LIBRARY_PAYLOAD.SONG_DATA]; + draft[LIBRARY_PAYLOAD.LIKED_SONGS].push(song); + draft[LIBRARY_PAYLOAD.LIKED_TRACK_IDS][song.trackId] = true; +}; + +const handleUnlikeSuccess = (draft, action) => { + const trackId = action[LIBRARY_PAYLOAD.TRACK_ID]; + draft[LIBRARY_PAYLOAD.LIKED_SONGS] = draft[LIBRARY_PAYLOAD.LIKED_SONGS].filter((s) => s.trackId !== trackId); + delete draft[LIBRARY_PAYLOAD.LIKED_TRACK_IDS][trackId]; +}; + +const handlers = { + [libraryTypes.REQUEST_FETCH_LIBRARY]: handleFetchRequest, + [libraryTypes.SUCCESS_FETCH_LIBRARY]: handleFetchSuccess, + [libraryTypes.FAILURE_FETCH_LIBRARY]: handleFetchFailure, + [libraryTypes.SUCCESS_LIKE_SONG]: handleLikeSuccess, + [libraryTypes.FAILURE_LIKE_SONG]: handleFetchFailure, + [libraryTypes.SUCCESS_UNLIKE_SONG]: handleUnlikeSuccess, + [libraryTypes.FAILURE_UNLIKE_SONG]: handleFetchFailure, + [libraryTypes.CLEAR_LIBRARY]: () => initialState +}; + +export const libraryReducer = (state = initialState, action) => + produce(state, (draft) => { + const handler = handlers[action.type]; + return handler ? handler(draft, action) : state; + }); + +export default libraryReducer; diff --git a/app/containers/Library/saga.js b/app/containers/Library/saga.js new file mode 100644 index 0000000..2634ab7 --- /dev/null +++ b/app/containers/Library/saga.js @@ -0,0 +1,43 @@ +import { call, put, takeLatest } from 'redux-saga/effects'; +import { fetchLibrary, likeSong, unlikeSong } from '@services/libraryApi'; +import { libraryTypes, libraryCreators } from './reducer'; +import { LIBRARY_PAYLOAD } from './constants'; + +const { successFetchLibrary, failureFetchLibrary } = libraryCreators; +const { successLikeSong, failureLikeSong } = libraryCreators; +const { successUnlikeSong, failureUnlikeSong } = libraryCreators; + +export function* handleFetchLibrary() { + const response = yield call(fetchLibrary); + if (response.ok) { + yield put(successFetchLibrary(response.data)); + } else { + yield put(failureFetchLibrary(response.data)); + } +} + +export function* handleLikeSong(action) { + const songData = action[LIBRARY_PAYLOAD.SONG_DATA]; + const response = yield call(likeSong, songData); + if (response.ok) { + yield put(successLikeSong(songData)); + } else { + yield put(failureLikeSong(response.data)); + } +} + +export function* handleUnlikeSong(action) { + const trackId = action[LIBRARY_PAYLOAD.TRACK_ID]; + const response = yield call(unlikeSong, trackId); + if (response.ok) { + yield put(successUnlikeSong(trackId)); + } else { + yield put(failureUnlikeSong(response.data)); + } +} + +export default function* librarySaga() { + yield takeLatest(libraryTypes.REQUEST_FETCH_LIBRARY, handleFetchLibrary); + yield takeLatest(libraryTypes.REQUEST_LIKE_SONG, handleLikeSong); + yield takeLatest(libraryTypes.REQUEST_UNLIKE_SONG, handleUnlikeSong); +} diff --git a/app/containers/Library/selectors.js b/app/containers/Library/selectors.js new file mode 100644 index 0000000..964cb38 --- /dev/null +++ b/app/containers/Library/selectors.js @@ -0,0 +1,17 @@ +import { PAYLOAD } from '@app/utils/reducer'; +import get from 'lodash/get'; +import { createSelector } from 'reselect'; +import { initialState } from './reducer'; +import { LIBRARY_PAYLOAD } from './constants'; + +const selectLibraryDomain = (state) => state.library || initialState; + +export const selectLikedSongs = () => + createSelector(selectLibraryDomain, (s) => get(s, LIBRARY_PAYLOAD.LIKED_SONGS, [])); + +export const selectLikedTrackIds = () => + createSelector(selectLibraryDomain, (s) => get(s, LIBRARY_PAYLOAD.LIKED_TRACK_IDS, {})); + +export const selectLibraryLoading = () => createSelector(selectLibraryDomain, (s) => get(s, PAYLOAD.LOADING, false)); + +export const selectLibraryError = () => createSelector(selectLibraryDomain, (s) => get(s, PAYLOAD.ERROR, null)); diff --git a/app/containers/Library/tests/index.test.js b/app/containers/Library/tests/index.test.js new file mode 100644 index 0000000..75576c7 --- /dev/null +++ b/app/containers/Library/tests/index.test.js @@ -0,0 +1,132 @@ +import React from 'react' +import { renderProvider } from '@utils/testUtils' +import { LibraryTest as Library } from '../index' + +const mockPush = jest.fn() +jest.mock('next/router', () => ({ + useRouter: () => ({ push: mockPush }) +})) + +jest.mock('@components/ThemeToggle', () => { + const Mock = () =>
+ Mock.displayName = 'MockThemeToggle' + return Mock +}) + +jest.mock('@components/LogoutButton', () => { + const Mock = () =>
+ Mock.displayName = 'MockLogoutButton' + return Mock +}) + +jest.mock('@components/NavLink', () => { + const PT = require('prop-types') + const Mock = ({ label }) => ( + {label} + ) + Mock.displayName = 'MockNavLink' + Mock.propTypes = { label: PT.string } + return Mock +}) + +jest.mock('@components/AudioPlayer', () => { + const Mock = () =>
+ Mock.displayName = 'MockAudioPlayer' + return Mock +}) + +jest.mock('@components/ArtworkPlayButton', () => { + const PT = require('prop-types') + const Mock = ({ onClick }) => ( + + ) + Mock.displayName = 'MockArtworkPlayButton' + Mock.propTypes = { onClick: PT.func } + return Mock +}) + +const mockSongs = [ + { + trackId: 1, + trackName: 'Song A', + artistName: 'Artist A', + artworkUrl: 'a.jpg', + albumName: 'Album A' + }, + { + trackId: 2, + trackName: 'Song B', + artistName: 'Artist B', + artworkUrl: 'b.jpg', + albumName: 'Album B' + } +] + +describe(' container', () => { + const defaultProps = { + likedSongs: [], + likedTrackIds: {}, + loading: false, + currentSong: null, + isPlaying: false, + dispatchFetchLibrary: jest.fn(), + dispatchSetSong: jest.fn(), + dispatchLike: jest.fn(), + dispatchUnlike: jest.fn(), + dispatchSetIsPlaying: jest.fn() + } + + beforeEach(() => jest.clearAllMocks()) + + it('should render the page title', () => { + const { getByText } = renderProvider() + expect(getByText('MUSICA')).toBeTruthy() + }) + + it('should render navigation links', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('nav-search')).toBeTruthy() + expect(getByTestId('nav-favorites')).toBeTruthy() + }) + + it('should fetch library on mount', () => { + renderProvider() + expect(defaultProps.dispatchFetchLibrary).toHaveBeenCalledTimes(1) + }) + + it('should render empty state when no liked songs', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('empty-library')).toBeTruthy() + }) + + it('should render liked songs', () => { + const props = { + ...defaultProps, + likedSongs: mockSongs, + likedTrackIds: { 1: true, 2: true } + } + const { getByTestId } = renderProvider() + expect(getByTestId('song-1')).toBeTruthy() + expect(getByTestId('song-2')).toBeTruthy() + }) + + it('should render loading spinner when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('loading-spinner')).toBeTruthy() + }) + + it('should navigate to track detail when card clicked', () => { + const props = { + ...defaultProps, + likedSongs: mockSongs, + likedTrackIds: { 1: true, 2: true } + } + const { getByTestId } = renderProvider() + const { fireEvent } = require('@testing-library/react') + fireEvent.click(getByTestId('song-1')) + expect(mockPush).toHaveBeenCalledWith('/track/1') + }) +}) diff --git a/app/containers/Library/tests/reducer.test.js b/app/containers/Library/tests/reducer.test.js new file mode 100644 index 0000000..006646a --- /dev/null +++ b/app/containers/Library/tests/reducer.test.js @@ -0,0 +1,83 @@ +import { + libraryReducer as reducer, + initialState, + libraryTypes +} from '../reducer' + +const mockSong = { + trackId: 1, + trackName: 'Song A', + artistName: 'Artist A', + albumName: 'Album A', + previewUrl: 'a.mp3', + artworkUrl: 'a.jpg' +} + +const mockSongB = { + trackId: 2, + trackName: 'Song B', + artistName: 'Artist B', + albumName: 'Album B', + previewUrl: 'b.mp3', + artworkUrl: 'b.jpg' +} + +describe('Library reducer', () => { + it('should return the initial state', () => { + expect(reducer(undefined, {})).toEqual(initialState) + }) + + it('should handle REQUEST_FETCH_LIBRARY', () => { + const action = { type: libraryTypes.REQUEST_FETCH_LIBRARY } + const state = reducer(initialState, action) + expect(state.loading).toBe(true) + expect(state.error).toBeNull() + }) + + it('should handle SUCCESS_FETCH_LIBRARY', () => { + const songs = [mockSong, mockSongB] + const action = { type: libraryTypes.SUCCESS_FETCH_LIBRARY, data: songs } + const state = reducer(initialState, action) + expect(state.likedSongs).toEqual(songs) + expect(state.likedTrackIds).toEqual({ 1: true, 2: true }) + expect(state.loading).toBe(false) + }) + + it('should handle FAILURE_FETCH_LIBRARY', () => { + const action = { type: libraryTypes.FAILURE_FETCH_LIBRARY, error: 'fail' } + const state = reducer(initialState, action) + expect(state.error).toBe('fail') + expect(state.loading).toBe(false) + }) + + it('should handle SUCCESS_LIKE_SONG', () => { + const action = { type: libraryTypes.SUCCESS_LIKE_SONG, songData: mockSong } + const state = reducer(initialState, action) + expect(state.likedSongs).toEqual([mockSong]) + expect(state.likedTrackIds[1]).toBe(true) + }) + + it('should handle SUCCESS_UNLIKE_SONG', () => { + const withSongs = { + ...initialState, + likedSongs: [mockSong, mockSongB], + likedTrackIds: { 1: true, 2: true } + } + const action = { type: libraryTypes.SUCCESS_UNLIKE_SONG, trackId: 1 } + const state = reducer(withSongs, action) + expect(state.likedSongs).toEqual([mockSongB]) + expect(state.likedTrackIds[1]).toBeUndefined() + expect(state.likedTrackIds[2]).toBe(true) + }) + + it('should handle CLEAR_LIBRARY', () => { + const withSongs = { + ...initialState, + likedSongs: [mockSong], + likedTrackIds: { 1: true } + } + const action = { type: libraryTypes.CLEAR_LIBRARY } + const state = reducer(withSongs, action) + expect(state).toEqual(initialState) + }) +}) diff --git a/app/containers/Library/tests/saga.test.js b/app/containers/Library/tests/saga.test.js new file mode 100644 index 0000000..8fc14f4 --- /dev/null +++ b/app/containers/Library/tests/saga.test.js @@ -0,0 +1,95 @@ +import { takeLatest, call, put } from 'redux-saga/effects' +import { fetchLibrary, likeSong, unlikeSong } from '@services/libraryApi' +import { apiResponseGenerator } from '@utils/testUtils' +import librarySaga, { + handleFetchLibrary, + handleLikeSong, + handleUnlikeSong +} from '../saga' +import { libraryTypes } from '../reducer' + +const mockSong = { trackId: 1, trackName: 'Song A', artistName: 'Artist A' } + +describe('Library saga tests', () => { + const generator = librarySaga() + + it('should watch for REQUEST_FETCH_LIBRARY', () => { + expect(generator.next().value).toEqual( + takeLatest(libraryTypes.REQUEST_FETCH_LIBRARY, handleFetchLibrary) + ) + }) + + it('should watch for REQUEST_LIKE_SONG', () => { + expect(generator.next().value).toEqual( + takeLatest(libraryTypes.REQUEST_LIKE_SONG, handleLikeSong) + ) + }) + + it('should watch for REQUEST_UNLIKE_SONG', () => { + expect(generator.next().value).toEqual( + takeLatest(libraryTypes.REQUEST_UNLIKE_SONG, handleUnlikeSong) + ) + }) + + describe('handleFetchLibrary', () => { + it('should dispatch SUCCESS on success', () => { + const gen = handleFetchLibrary() + expect(gen.next().value).toEqual(call(fetchLibrary)) + const data = [mockSong] + expect(gen.next(apiResponseGenerator(true, data)).value).toEqual( + put({ type: libraryTypes.SUCCESS_FETCH_LIBRARY, data }) + ) + }) + + it('should dispatch FAILURE on error', () => { + const gen = handleFetchLibrary() + gen.next() + const error = { message: 'fail' } + expect(gen.next(apiResponseGenerator(false, error)).value).toEqual( + put({ type: libraryTypes.FAILURE_FETCH_LIBRARY, error }) + ) + }) + }) + + describe('handleLikeSong', () => { + const action = { songData: mockSong } + + it('should dispatch SUCCESS on success', () => { + const gen = handleLikeSong(action) + expect(gen.next().value).toEqual(call(likeSong, mockSong)) + expect(gen.next(apiResponseGenerator(true, {})).value).toEqual( + put({ type: libraryTypes.SUCCESS_LIKE_SONG, songData: mockSong }) + ) + }) + + it('should dispatch FAILURE on error', () => { + const gen = handleLikeSong(action) + gen.next() + const error = { message: 'fail' } + expect(gen.next(apiResponseGenerator(false, error)).value).toEqual( + put({ type: libraryTypes.FAILURE_LIKE_SONG, error }) + ) + }) + }) + + describe('handleUnlikeSong', () => { + const action = { trackId: 1 } + + it('should dispatch SUCCESS on success', () => { + const gen = handleUnlikeSong(action) + expect(gen.next().value).toEqual(call(unlikeSong, 1)) + expect(gen.next(apiResponseGenerator(true, {})).value).toEqual( + put({ type: libraryTypes.SUCCESS_UNLIKE_SONG, trackId: 1 }) + ) + }) + + it('should dispatch FAILURE on error', () => { + const gen = handleUnlikeSong(action) + gen.next() + const error = { message: 'fail' } + expect(gen.next(apiResponseGenerator(false, error)).value).toEqual( + put({ type: libraryTypes.FAILURE_UNLIKE_SONG, error }) + ) + }) + }) +}) diff --git a/app/containers/Library/tests/selectors.test.js b/app/containers/Library/tests/selectors.test.js new file mode 100644 index 0000000..5588b0b --- /dev/null +++ b/app/containers/Library/tests/selectors.test.js @@ -0,0 +1,41 @@ +import { + selectLikedSongs, + selectLikedTrackIds, + selectLibraryLoading, + selectLibraryError +} from '../selectors' + +describe('Library selectors', () => { + const mockState = { + library: { + likedSongs: [{ trackId: 1 }], + likedTrackIds: { 1: true }, + loading: true, + error: 'oops' + } + } + + it('should select liked songs', () => { + expect(selectLikedSongs()(mockState)).toEqual([{ trackId: 1 }]) + }) + + it('should select liked track ids', () => { + expect(selectLikedTrackIds()(mockState)).toEqual({ 1: true }) + }) + + it('should select loading state', () => { + expect(selectLibraryLoading()(mockState)).toBe(true) + }) + + it('should select error state', () => { + expect(selectLibraryError()(mockState)).toBe('oops') + }) + + it('should return defaults when library state is missing', () => { + const empty = {} + expect(selectLikedSongs()(empty)).toEqual([]) + expect(selectLikedTrackIds()(empty)).toEqual({}) + expect(selectLibraryLoading()(empty)).toBe(false) + expect(selectLibraryError()(empty)).toBeNull() + }) +}) diff --git a/app/containers/Music/constants.js b/app/containers/Music/constants.js new file mode 100644 index 0000000..ac4600b --- /dev/null +++ b/app/containers/Music/constants.js @@ -0,0 +1,8 @@ +export const MUSIC_PAYLOAD = { + SEARCH_TERM: 'searchTerm', + SONGS: 'songs', + CURRENT_SONG: 'currentSong', + IS_PLAYING: 'isPlaying' +}; + +export const SEARCH_DEBOUNCE_MS = 400; diff --git a/app/containers/Music/index.js b/app/containers/Music/index.js new file mode 100644 index 0000000..689a950 --- /dev/null +++ b/app/containers/Music/index.js @@ -0,0 +1,139 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import { createStructuredSelector } from 'reselect'; +import debounce from 'lodash/debounce'; +import injectSaga from '@utils/injectSaga'; +import SearchBar from '@components/SearchBar'; +import SongList from '@components/SongList'; +import AudioPlayer from '@components/AudioPlayer'; +import If from '@components/If'; +import ThemeToggle from '@components/ThemeToggle'; +import LogoutButton from '@components/LogoutButton'; +import NavLink from '@components/NavLink'; +import { + MusicPageWrapper, + MusicPageContent, + PageHeader, + PageTitle, + HeaderActions, + EmptyState, + LoadingSpinner +} from '@components/styled/musicPage'; +import { NavGroup } from '@components/styled/navLink'; +import { musicCreators } from './reducer'; +import { libraryCreators } from '@app/containers/Library/reducer'; +import saga from './saga'; +import librarySaga from '@app/containers/Library/saga'; +import { selectMusicSongs, selectMusicLoading, selectCurrentSong, selectIsPlaying } from './selectors'; +import { selectLikedTrackIds } from '@app/containers/Library/selectors'; +import { SEARCH_DEBOUNCE_MS } from './constants'; +import usePlaybackNav from './usePlaybackNav'; +import useToggleLike from './useToggleLike'; +import usePlayToggle from './usePlayToggle'; + +export function Music(props) { + const { songs, loading, currentSong, isPlaying, dispatchSearch, dispatchSetSong } = props; + const { likedTrackIds, dispatchFetchLibrary, dispatchLike, dispatchUnlike, dispatchSetIsPlaying } = props; + const [searchValue, setSearchValue] = useState(''); + + useEffect(() => { + dispatchFetchLibrary(); + }, []); + + const debouncedSearch = useCallback( + debounce((term) => dispatchSearch(term), SEARCH_DEBOUNCE_MS), + [] + ); + const handleSearch = (value) => { + setSearchValue(value); + debouncedSearch(value); + }; + const { handleNext, handlePrev } = usePlaybackNav({ songs, currentSong, dispatchSetSong }); + const handleToggleLike = useToggleLike({ likedTrackIds, dispatchLike, dispatchUnlike }); + const { handlePlayToggle, registerTogglePlay } = usePlayToggle({ currentSong, dispatchSetSong }); + + return ( + + + + MUSICA + + + + + + + + + + + + + + 0}> + + + 0}> + No songs found. Try a different search. + + + + + ); +} + +Music.propTypes = { + songs: PropTypes.array, + loading: PropTypes.bool, + currentSong: PropTypes.object, + isPlaying: PropTypes.bool, + likedTrackIds: PropTypes.object, + dispatchSearch: PropTypes.func.isRequired, + dispatchSetSong: PropTypes.func.isRequired, + dispatchFetchLibrary: PropTypes.func.isRequired, + dispatchLike: PropTypes.func.isRequired, + dispatchUnlike: PropTypes.func.isRequired, + dispatchSetIsPlaying: PropTypes.func.isRequired +}; + +const mapStateToProps = createStructuredSelector({ + songs: selectMusicSongs(), + loading: selectMusicLoading(), + currentSong: selectCurrentSong(), + isPlaying: selectIsPlaying(), + likedTrackIds: selectLikedTrackIds() +}); + +function mapDispatchToProps(dispatch) { + return { + dispatchSearch: (term) => dispatch(musicCreators.requestSearchSongs(term)), + dispatchSetSong: (song) => dispatch(musicCreators.setCurrentSong(song)), + dispatchSetIsPlaying: (val) => dispatch(musicCreators.setIsPlaying(val)), + dispatchFetchLibrary: () => dispatch(libraryCreators.requestFetchLibrary()), + dispatchLike: (song) => dispatch(libraryCreators.requestLikeSong(song)), + dispatchUnlike: (id) => dispatch(libraryCreators.requestUnlikeSong(id)) + }; +} + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + injectSaga({ key: 'music', saga }), + injectSaga({ key: 'library', saga: librarySaga }) +)(Music); + +export const MusicTest = Music; diff --git a/app/containers/Music/reducer.js b/app/containers/Music/reducer.js new file mode 100644 index 0000000..b031969 --- /dev/null +++ b/app/containers/Music/reducer.js @@ -0,0 +1,64 @@ +import { PAYLOAD, startLoading, stopLoading, setError } from '@app/utils/reducer'; +import produce from 'immer'; +import { createActions } from 'reduxsauce'; +import { MUSIC_PAYLOAD } from './constants'; + +export const initialState = { + [MUSIC_PAYLOAD.SEARCH_TERM]: '', + [MUSIC_PAYLOAD.SONGS]: [], + [MUSIC_PAYLOAD.CURRENT_SONG]: null, + [MUSIC_PAYLOAD.IS_PLAYING]: false, + [PAYLOAD.ERROR]: null, + [PAYLOAD.LOADING]: false +}; + +export const { Types: musicTypes, Creators: musicCreators } = createActions({ + requestSearchSongs: [MUSIC_PAYLOAD.SEARCH_TERM], + successSearchSongs: [PAYLOAD.DATA], + failureSearchSongs: [PAYLOAD.ERROR], + setCurrentSong: [MUSIC_PAYLOAD.CURRENT_SONG], + setIsPlaying: [MUSIC_PAYLOAD.IS_PLAYING], + clearMusic: null +}); + +const handleRequest = (draft, action) => { + startLoading(draft); + draft[PAYLOAD.ERROR] = null; + draft[MUSIC_PAYLOAD.SEARCH_TERM] = action[MUSIC_PAYLOAD.SEARCH_TERM]; +}; + +const handleSuccess = (draft, action) => { + stopLoading(draft); + draft[MUSIC_PAYLOAD.SONGS] = action[PAYLOAD.DATA] || []; +}; + +const handleFailure = (draft, action) => { + stopLoading(draft); + setError(draft, action); +}; + +const handleSetSong = (draft, action) => { + draft[MUSIC_PAYLOAD.CURRENT_SONG] = action[MUSIC_PAYLOAD.CURRENT_SONG]; + draft[MUSIC_PAYLOAD.IS_PLAYING] = true; +}; + +const handleSetIsPlaying = (draft, action) => { + draft[MUSIC_PAYLOAD.IS_PLAYING] = action[MUSIC_PAYLOAD.IS_PLAYING]; +}; + +const handlers = { + [musicTypes.REQUEST_SEARCH_SONGS]: handleRequest, + [musicTypes.SUCCESS_SEARCH_SONGS]: handleSuccess, + [musicTypes.FAILURE_SEARCH_SONGS]: handleFailure, + [musicTypes.SET_CURRENT_SONG]: handleSetSong, + [musicTypes.SET_IS_PLAYING]: handleSetIsPlaying, + [musicTypes.CLEAR_MUSIC]: () => initialState +}; + +export const musicReducer = (state = initialState, action) => + produce(state, (draft) => { + const handler = handlers[action.type]; + return handler ? handler(draft, action) : state; + }); + +export default musicReducer; diff --git a/app/containers/Music/saga.js b/app/containers/Music/saga.js new file mode 100644 index 0000000..9ab7ec1 --- /dev/null +++ b/app/containers/Music/saga.js @@ -0,0 +1,20 @@ +import { call, put, takeLatest } from 'redux-saga/effects'; +import { searchSongs } from '@services/musicApi'; +import { musicTypes, musicCreators } from './reducer'; +import { MUSIC_PAYLOAD } from './constants'; + +const { successSearchSongs, failureSearchSongs } = musicCreators; + +export function* handleSearchSongs(action) { + const term = action[MUSIC_PAYLOAD.SEARCH_TERM]; + const response = yield call(searchSongs, term); + if (response.ok) { + yield put(successSearchSongs(response.data)); + } else { + yield put(failureSearchSongs(response.data)); + } +} + +export default function* musicSaga() { + yield takeLatest(musicTypes.REQUEST_SEARCH_SONGS, handleSearchSongs); +} diff --git a/app/containers/Music/selectors.js b/app/containers/Music/selectors.js new file mode 100644 index 0000000..a690175 --- /dev/null +++ b/app/containers/Music/selectors.js @@ -0,0 +1,20 @@ +import { PAYLOAD } from '@app/utils/reducer'; +import get from 'lodash/get'; +import { createSelector } from 'reselect'; +import { initialState } from './reducer'; +import { MUSIC_PAYLOAD } from './constants'; + +const selectMusicDomain = (state) => state.music || initialState; + +export const selectMusicSongs = () => createSelector(selectMusicDomain, (s) => get(s, MUSIC_PAYLOAD.SONGS, [])); + +export const selectMusicLoading = () => createSelector(selectMusicDomain, (s) => get(s, PAYLOAD.LOADING, false)); + +export const selectMusicError = () => createSelector(selectMusicDomain, (s) => get(s, PAYLOAD.ERROR, null)); + +export const selectCurrentSong = () => + createSelector(selectMusicDomain, (s) => get(s, MUSIC_PAYLOAD.CURRENT_SONG, null)); + +export const selectSearchTerm = () => createSelector(selectMusicDomain, (s) => get(s, MUSIC_PAYLOAD.SEARCH_TERM, '')); + +export const selectIsPlaying = () => createSelector(selectMusicDomain, (s) => get(s, MUSIC_PAYLOAD.IS_PLAYING, false)); diff --git a/app/containers/Music/tests/index.test.js b/app/containers/Music/tests/index.test.js new file mode 100644 index 0000000..0fdc603 --- /dev/null +++ b/app/containers/Music/tests/index.test.js @@ -0,0 +1,127 @@ +import React from 'react' +import { renderProvider } from '@utils/testUtils' +import { MusicTest as Music } from '../index' + +const mockPush = jest.fn() +jest.mock('next/router', () => ({ + useRouter: () => ({ push: mockPush }) +})) + +jest.mock('@components/ThemeToggle', () => { + const Mock = () =>
+ Mock.displayName = 'MockThemeToggle' + return Mock +}) + +jest.mock('@components/LogoutButton', () => { + const Mock = () =>
+ Mock.displayName = 'MockLogoutButton' + return Mock +}) + +jest.mock('@components/NavLink', () => { + const PT = require('prop-types') + const Mock = ({ label }) => ( + {label} + ) + Mock.displayName = 'MockNavLink' + Mock.propTypes = { label: PT.string } + return Mock +}) + +jest.mock('@components/AudioPlayer', () => { + const Mock = () =>
+ Mock.displayName = 'MockAudioPlayer' + return Mock +}) + +jest.mock('@components/ArtworkPlayButton', () => { + const PT = require('prop-types') + const Mock = ({ onClick }) => ( + + ) + Mock.displayName = 'MockArtworkPlayButton' + Mock.propTypes = { onClick: PT.func } + return Mock +}) + +const mockSongs = [ + { + trackId: 1, + trackName: 'Song A', + artistName: 'Artist A', + artworkUrl: 'a.jpg', + previewUrl: 'a.mp3', + albumName: 'Album A' + }, + { + trackId: 2, + trackName: 'Song B', + artistName: 'Artist B', + artworkUrl: 'b.jpg', + previewUrl: 'b.mp3', + albumName: 'Album B' + } +] + +describe(' container', () => { + const defaultProps = { + songs: [], + loading: false, + currentSong: null, + isPlaying: false, + likedTrackIds: {}, + dispatchSearch: jest.fn(), + dispatchSetSong: jest.fn(), + dispatchFetchLibrary: jest.fn(), + dispatchLike: jest.fn(), + dispatchUnlike: jest.fn(), + dispatchSetIsPlaying: jest.fn() + } + + beforeEach(() => jest.clearAllMocks()) + + it('should render the page title', () => { + const { getByText } = renderProvider() + expect(getByText('MUSICA')).toBeTruthy() + }) + + it('should render navigation links', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('nav-search')).toBeTruthy() + expect(getByTestId('nav-favorites')).toBeTruthy() + }) + + it('should render the search bar', () => { + const { getByTestId } = renderProvider() + expect(getByTestId('music-search-input')).toBeTruthy() + }) + + it('should fetch library on mount', () => { + renderProvider() + expect(defaultProps.dispatchFetchLibrary).toHaveBeenCalledTimes(1) + }) + + it('should render songs when provided', () => { + const props = { ...defaultProps, songs: mockSongs } + const { getByTestId } = renderProvider() + expect(getByTestId('song-1')).toBeTruthy() + expect(getByTestId('song-2')).toBeTruthy() + }) + + it('should render loading spinner when loading', () => { + const props = { ...defaultProps, loading: true } + const { getByTestId } = renderProvider() + expect(getByTestId('loading-spinner')).toBeTruthy() + }) + + it('should navigate to track detail when card clicked', () => { + const props = { ...defaultProps, songs: mockSongs } + const { getByTestId } = renderProvider() + const { fireEvent } = require('@testing-library/react') + fireEvent.click(getByTestId('song-1')) + expect(mockPush).toHaveBeenCalledWith('/track/1') + }) +}) diff --git a/app/containers/Music/tests/reducer.test.js b/app/containers/Music/tests/reducer.test.js new file mode 100644 index 0000000..9fda79d --- /dev/null +++ b/app/containers/Music/tests/reducer.test.js @@ -0,0 +1,79 @@ +import { PAYLOAD } from '@app/utils/reducer' +import { musicReducer, initialState, musicTypes } from '../reducer' +import { MUSIC_PAYLOAD } from '../constants' + +describe('Music reducer tests', () => { + let state + beforeEach(() => { + state = initialState + }) + + it('should return the initial state', () => { + expect(musicReducer(undefined, {})).toEqual(state) + }) + + it('should set loading and searchTerm on REQUEST_SEARCH_SONGS', () => { + const expected = { + ...state, + loading: true, + error: null, + searchTerm: 'senorita' + } + expect( + musicReducer(state, { + type: musicTypes.REQUEST_SEARCH_SONGS, + [MUSIC_PAYLOAD.SEARCH_TERM]: 'senorita' + }) + ).toEqual(expected) + }) + + it('should set songs on SUCCESS_SEARCH_SONGS', () => { + const songs = [{ trackId: 1, trackName: 'Song' }] + const expected = { ...state, songs, loading: false } + expect( + musicReducer(state, { + type: musicTypes.SUCCESS_SEARCH_SONGS, + [PAYLOAD.DATA]: songs + }) + ).toEqual(expected) + }) + + it('should set error on FAILURE_SEARCH_SONGS', () => { + const expected = { + ...state, + [PAYLOAD.ERROR]: 'something_went_wrong', + loading: false + } + expect( + musicReducer(state, { type: musicTypes.FAILURE_SEARCH_SONGS }) + ).toEqual(expected) + }) + + it('should set currentSong and isPlaying on SET_CURRENT_SONG', () => { + const song = { trackId: 1, trackName: 'Song' } + const expected = { ...state, currentSong: song, isPlaying: true } + expect( + musicReducer(state, { + type: musicTypes.SET_CURRENT_SONG, + [MUSIC_PAYLOAD.CURRENT_SONG]: song + }) + ).toEqual(expected) + }) + + it('should set isPlaying on SET_IS_PLAYING', () => { + const expected = { ...state, isPlaying: true } + expect( + musicReducer(state, { + type: musicTypes.SET_IS_PLAYING, + [MUSIC_PAYLOAD.IS_PLAYING]: true + }) + ).toEqual(expected) + }) + + it('should reset state on CLEAR_MUSIC', () => { + const modified = { ...state, songs: [{ trackId: 1 }], loading: true } + expect(musicReducer(modified, { type: musicTypes.CLEAR_MUSIC })).toEqual( + initialState + ) + }) +}) diff --git a/app/containers/Music/tests/saga.test.js b/app/containers/Music/tests/saga.test.js new file mode 100644 index 0000000..231d864 --- /dev/null +++ b/app/containers/Music/tests/saga.test.js @@ -0,0 +1,38 @@ +import { takeLatest, call, put } from 'redux-saga/effects' +import { searchSongs } from '@services/musicApi' +import { apiResponseGenerator } from '@utils/testUtils' +import musicSaga, { handleSearchSongs } from '../saga' +import { musicTypes } from '../reducer' +import { MUSIC_PAYLOAD } from '../constants' + +describe('Music saga tests', () => { + const generator = musicSaga() + + it('should watch for REQUEST_SEARCH_SONGS action', () => { + expect(generator.next().value).toEqual( + takeLatest(musicTypes.REQUEST_SEARCH_SONGS, handleSearchSongs) + ) + }) + + describe('handleSearchSongs', () => { + const action = { [MUSIC_PAYLOAD.SEARCH_TERM]: 'senorita' } + + it('should dispatch SUCCESS on successful search', () => { + const gen = handleSearchSongs(action) + expect(gen.next().value).toEqual(call(searchSongs, 'senorita')) + const songs = [{ trackId: 1, trackName: 'Señorita' }] + expect(gen.next(apiResponseGenerator(true, songs)).value).toEqual( + put({ type: musicTypes.SUCCESS_SEARCH_SONGS, data: songs }) + ) + }) + + it('should dispatch FAILURE on failed search', () => { + const gen = handleSearchSongs(action) + gen.next() + const errorData = { message: 'Search failed' } + expect(gen.next(apiResponseGenerator(false, errorData)).value).toEqual( + put({ type: musicTypes.FAILURE_SEARCH_SONGS, error: errorData }) + ) + }) + }) +}) diff --git a/app/containers/Music/tests/selectors.test.js b/app/containers/Music/tests/selectors.test.js new file mode 100644 index 0000000..a2339d0 --- /dev/null +++ b/app/containers/Music/tests/selectors.test.js @@ -0,0 +1,59 @@ +import { + selectMusicSongs, + selectMusicLoading, + selectMusicError, + selectCurrentSong, + selectSearchTerm, + selectIsPlaying +} from '../selectors' + +describe('Music selector tests', () => { + let mockedState + + beforeEach(() => { + mockedState = { + music: { + songs: [{ trackId: 1, trackName: 'Test Song' }], + loading: true, + error: 'Some error', + currentSong: { trackId: 1 }, + searchTerm: 'test', + isPlaying: true + } + } + }) + + it('should select songs', () => { + expect(selectMusicSongs()(mockedState)).toEqual(mockedState.music.songs) + }) + + it('should select loading', () => { + expect(selectMusicLoading()(mockedState)).toBe(true) + }) + + it('should select error', () => { + expect(selectMusicError()(mockedState)).toBe('Some error') + }) + + it('should select currentSong', () => { + expect(selectCurrentSong()(mockedState)).toEqual({ trackId: 1 }) + }) + + it('should select searchTerm', () => { + expect(selectSearchTerm()(mockedState)).toBe('test') + }) + + it('should select isPlaying', () => { + expect(selectIsPlaying()(mockedState)).toBe(true) + }) + + it('should return defaults when music state is empty', () => { + const empty = {} + expect(selectMusicSongs()(empty)).toEqual([]) + expect(selectMusicLoading()(empty)).toBe(false) + expect(selectMusicError()(empty)).toBeNull() + expect(selectCurrentSong()(empty)).toBeNull() + expect(selectSearchTerm()(empty)).toBe('') + expect(selectIsPlaying()(empty)).toBe(false) + }) +}) diff --git a/app/containers/Music/tests/usePlayToggle.test.js b/app/containers/Music/tests/usePlayToggle.test.js new file mode 100644 index 0000000..b9a30b8 --- /dev/null +++ b/app/containers/Music/tests/usePlayToggle.test.js @@ -0,0 +1,38 @@ +import { renderHook, act } from '@testing-library/react' +import usePlayToggle from '../usePlayToggle' + +describe('usePlayToggle hook', () => { + const mockSetSong = jest.fn() + const currentSong = { trackId: 1, trackName: 'Song A' } + + beforeEach(() => jest.clearAllMocks()) + + it('should call dispatchSetSong for a different song', () => { + const { result } = renderHook(() => + usePlayToggle({ currentSong, dispatchSetSong: mockSetSong }) + ) + const otherSong = { trackId: 2, trackName: 'Song B' } + act(() => result.current.handlePlayToggle(otherSong)) + expect(mockSetSong).toHaveBeenCalledWith(otherSong) + }) + + it('should call togglePlay for the same song', () => { + const mockToggle = jest.fn() + const { result } = renderHook(() => + usePlayToggle({ currentSong, dispatchSetSong: mockSetSong }) + ) + act(() => result.current.registerTogglePlay(mockToggle)) + act(() => result.current.handlePlayToggle(currentSong)) + expect(mockToggle).toHaveBeenCalledTimes(1) + expect(mockSetSong).not.toHaveBeenCalled() + }) + + it('should not crash if togglePlay not registered', () => { + const { result } = renderHook(() => + usePlayToggle({ currentSong, dispatchSetSong: mockSetSong }) + ) + expect(() => { + act(() => result.current.handlePlayToggle(currentSong)) + }).not.toThrow() + }) +}) diff --git a/app/containers/Music/tests/usePlaybackNav.test.js b/app/containers/Music/tests/usePlaybackNav.test.js new file mode 100644 index 0000000..e4007c2 --- /dev/null +++ b/app/containers/Music/tests/usePlaybackNav.test.js @@ -0,0 +1,62 @@ +import { renderHook, act } from '@testing-library/react' +import usePlaybackNav from '../usePlaybackNav' + +const songs = [ + { trackId: 1, trackName: 'A' }, + { trackId: 2, trackName: 'B' }, + { trackId: 3, trackName: 'C' } +] + +describe('usePlaybackNav', () => { + const mockSetSong = jest.fn() + + beforeEach(() => mockSetSong.mockClear()) + + it('should go to next song', () => { + const { result } = renderHook(() => + usePlaybackNav({ + songs, + currentSong: songs[0], + dispatchSetSong: mockSetSong + }) + ) + act(() => result.current.handleNext()) + expect(mockSetSong).toHaveBeenCalledWith(songs[1]) + }) + + it('should go to previous song', () => { + const { result } = renderHook(() => + usePlaybackNav({ + songs, + currentSong: songs[1], + dispatchSetSong: mockSetSong + }) + ) + act(() => result.current.handlePrev()) + expect(mockSetSong).toHaveBeenCalledWith(songs[0]) + }) + + it('should not go past last song', () => { + const { result } = renderHook(() => + usePlaybackNav({ + songs, + currentSong: songs[2], + dispatchSetSong: mockSetSong + }) + ) + act(() => result.current.handleNext()) + expect(mockSetSong).not.toHaveBeenCalled() + }) + + it('should not go before first song', () => { + const { result } = renderHook(() => + usePlaybackNav({ + songs, + currentSong: songs[0], + dispatchSetSong: mockSetSong + }) + ) + act(() => result.current.handlePrev()) + expect(mockSetSong).not.toHaveBeenCalled() + }) +}) diff --git a/app/containers/Music/tests/useToggleLike.test.js b/app/containers/Music/tests/useToggleLike.test.js new file mode 100644 index 0000000..d8798be --- /dev/null +++ b/app/containers/Music/tests/useToggleLike.test.js @@ -0,0 +1,40 @@ +import { renderHook, act } from '@testing-library/react' +import useToggleLike from '../useToggleLike' + +const mockSong = { trackId: 1, trackName: 'Song A' } + +describe('useToggleLike', () => { + const mockLike = jest.fn() + const mockUnlike = jest.fn() + + beforeEach(() => { + mockLike.mockClear() + mockUnlike.mockClear() + }) + + it('should call dispatchLike for an unliked song', () => { + const { result } = renderHook(() => + useToggleLike({ + likedTrackIds: {}, + dispatchLike: mockLike, + dispatchUnlike: mockUnlike + }) + ) + act(() => result.current(mockSong)) + expect(mockLike).toHaveBeenCalledWith(mockSong) + expect(mockUnlike).not.toHaveBeenCalled() + }) + + it('should call dispatchUnlike for a liked song', () => { + const { result } = renderHook(() => + useToggleLike({ + likedTrackIds: { 1: true }, + dispatchLike: mockLike, + dispatchUnlike: mockUnlike + }) + ) + act(() => result.current(mockSong)) + expect(mockUnlike).toHaveBeenCalledWith(1) + expect(mockLike).not.toHaveBeenCalled() + }) +}) diff --git a/app/containers/Music/usePlayToggle.js b/app/containers/Music/usePlayToggle.js new file mode 100644 index 0000000..fde6cf7 --- /dev/null +++ b/app/containers/Music/usePlayToggle.js @@ -0,0 +1,26 @@ +import { useRef, useCallback } from 'react'; + +const usePlayToggle = ({ currentSong, dispatchSetSong }) => { + const togglePlayRef = useRef(null); + + const registerTogglePlay = useCallback((fn) => { + togglePlayRef.current = fn; + }, []); + + const handlePlayToggle = useCallback( + (song) => { + if (currentSong?.trackId === song.trackId) { + if (togglePlayRef.current) { + togglePlayRef.current(); + } + } else { + dispatchSetSong(song); + } + }, + [currentSong, dispatchSetSong] + ); + + return { handlePlayToggle, registerTogglePlay }; +}; + +export default usePlayToggle; diff --git a/app/containers/Music/usePlaybackNav.js b/app/containers/Music/usePlaybackNav.js new file mode 100644 index 0000000..07ea722 --- /dev/null +++ b/app/containers/Music/usePlaybackNav.js @@ -0,0 +1,23 @@ +import { useCallback } from 'react'; + +const usePlaybackNav = ({ songs, currentSong, dispatchSetSong }) => { + const findIdx = useCallback(() => songs.findIndex((s) => s.trackId === currentSong?.trackId), [songs, currentSong]); + + const handleNext = useCallback(() => { + const idx = findIdx(); + if (idx < songs.length - 1) { + dispatchSetSong(songs[idx + 1]); + } + }, [songs, findIdx, dispatchSetSong]); + + const handlePrev = useCallback(() => { + const idx = findIdx(); + if (idx > 0) { + dispatchSetSong(songs[idx - 1]); + } + }, [findIdx, dispatchSetSong]); + + return { handleNext, handlePrev }; +}; + +export default usePlaybackNav; diff --git a/app/containers/Music/useToggleLike.js b/app/containers/Music/useToggleLike.js new file mode 100644 index 0000000..b3c71b8 --- /dev/null +++ b/app/containers/Music/useToggleLike.js @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; + +const useToggleLike = ({ likedTrackIds, dispatchLike, dispatchUnlike }) => { + const handleToggleLike = useCallback( + (song) => { + if (likedTrackIds[song.trackId]) { + dispatchUnlike(song.trackId); + } else { + dispatchLike(song); + } + }, + [likedTrackIds, dispatchLike, dispatchUnlike] + ); + + return handleToggleLike; +}; + +export default useToggleLike; diff --git a/app/containers/Repos/tests/__snapshots__/index.test.js.snap b/app/containers/Repos/tests/__snapshots__/index.test.js.snap index 421faf4..f0a5837 100644 --- a/app/containers/Repos/tests/__snapshots__/index.test.js.snap +++ b/app/containers/Repos/tests/__snapshots__/index.test.js.snap @@ -124,14 +124,14 @@ exports[` container tests should render and match the snapshot 1`] = ` +
+ ) +} + +describe('ThemeContext', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('should default to dark theme', () => { + const { getByTestId } = render( + + + + ) + expect(getByTestId('mode').textContent).toBe('dark') + }) + + it('should toggle from dark to light', () => { + const { getByTestId } = render( + + + + ) + fireEvent.click(getByTestId('toggle')) + expect(getByTestId('mode').textContent).toBe('light') + }) + + it('should toggle back to dark', () => { + const { getByTestId } = render( + + + + ) + fireEvent.click(getByTestId('toggle')) + fireEvent.click(getByTestId('toggle')) + expect(getByTestId('mode').textContent).toBe('dark') + }) + + it('should persist theme to localStorage', () => { + const { getByTestId } = render( + + + + ) + fireEvent.click(getByTestId('toggle')) + expect(window.localStorage.getItem('musica_theme')).toBe('light') + }) + + it('should read initial theme from localStorage', () => { + window.localStorage.setItem('musica_theme', 'light') + const { getByTestId } = render( + + + + ) + expect(getByTestId('mode').textContent).toBe('light') + }) +}) diff --git a/app/global-styles.js b/app/global-styles.js index 45ea3c1..f762d8d 100644 --- a/app/global-styles.js +++ b/app/global-styles.js @@ -1,5 +1,4 @@ import { css } from '@emotion/react'; -import { colors } from '@themes'; const globalStyle = css` html, @@ -12,9 +11,9 @@ const globalStyle = css` p, label { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-family: 'Outfit', sans-serif; line-height: 1.5; - color: ${colors.text}; + color: var(--musica-text); } body { @@ -24,16 +23,16 @@ const globalStyle = css` div, h1 { line-height: 1.5; - font-family: Helvetica, Arial, sans-serif; - color: ${colors.text}; + font-family: 'Outfit', sans-serif; + color: var(--musica-text); } } body.fontLoaded { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-family: 'Outfit', sans-serif; } #app { - background-color: #fafafa; + background-color: var(--musica-bg); min-height: 100%; min-width: 100%; } diff --git a/app/reducers.js b/app/reducers.js index eb74141..8b6e0b9 100644 --- a/app/reducers.js +++ b/app/reducers.js @@ -7,6 +7,10 @@ import { combineReducers } from 'redux'; import repos from './containers/Repos/reducer'; import info from './containers/Info/reducer'; +import auth from './containers/Auth/reducer'; +import music from './containers/Music/reducer'; +import library from './containers/Library/reducer'; +import trackDetail from './containers/TrackDetail/reducer'; enableAllPlugins(); @@ -17,7 +21,11 @@ export default function createReducer(injectedReducer = {}) { const rootReducer = combineReducers({ ...injectedReducer, repos, - info + info, + auth, + music, + library, + trackDetail }); return rootReducer; diff --git a/app/services/authApi.js b/app/services/authApi.js new file mode 100644 index 0000000..dee2d61 --- /dev/null +++ b/app/services/authApi.js @@ -0,0 +1,7 @@ +import { generateApiClient } from '@utils/apiUtils'; + +const authApi = generateApiClient('auth'); + +export const loginUser = (credentials) => authApi.post('/login', credentials); + +export const signupUser = (userData) => authApi.post('/signup', userData); diff --git a/app/services/libraryApi.js b/app/services/libraryApi.js new file mode 100644 index 0000000..1339ec4 --- /dev/null +++ b/app/services/libraryApi.js @@ -0,0 +1,9 @@ +import { generateApiClient } from '@utils/apiUtils'; + +const musicApi = generateApiClient('music'); + +export const fetchLibrary = () => musicApi.get('/music/library'); + +export const likeSong = (songData) => musicApi.post('/music/library/like', songData); + +export const unlikeSong = (trackId) => musicApi.delete(`/music/library/unlike/${trackId}`); diff --git a/app/services/musicApi.js b/app/services/musicApi.js new file mode 100644 index 0000000..a2a0c3a --- /dev/null +++ b/app/services/musicApi.js @@ -0,0 +1,7 @@ +import { generateApiClient } from '@utils/apiUtils'; + +const musicApi = generateApiClient('music'); + +export const searchSongs = (term) => musicApi.get(`/music/resources/songs?term=${encodeURIComponent(term)}`); + +export const fetchTrackDetails = (trackId) => musicApi.get(`/music/resources/songs/${trackId}`); diff --git a/app/services/tests/libraryApi.test.js b/app/services/tests/libraryApi.test.js new file mode 100644 index 0000000..3aaa771 --- /dev/null +++ b/app/services/tests/libraryApi.test.js @@ -0,0 +1,35 @@ +import MockAdapter from 'axios-mock-adapter' +import { getApiClient } from '@utils/apiUtils' +import { fetchLibrary, likeSong, unlikeSong } from '../libraryApi' + +describe('libraryApi tests', () => { + let mock + + beforeEach(() => { + mock = new MockAdapter(getApiClient('music').axiosInstance) + }) + + afterEach(() => { + mock.restore() + }) + + it('should fetch the library via GET /music/library', async () => { + const data = [{ trackId: 1, trackName: 'Song A' }] + mock.onGet('/music/library').reply(200, data) + const res = await fetchLibrary() + expect(res.ok).toBe(true) + }) + + it('should like a song via POST /music/library/like', async () => { + const songData = { trackId: 1, trackName: 'Song A' } + mock.onPost('/music/library/like').reply(200, { success: true }) + const res = await likeSong(songData) + expect(res.ok).toBe(true) + }) + + it('should unlike a song via DELETE /music/library/unlike/:id', async () => { + mock.onDelete('/music/library/unlike/1').reply(200, { success: true }) + const res = await unlikeSong(1) + expect(res.ok).toBe(true) + }) +}) diff --git a/app/themes/palettes.js b/app/themes/palettes.js new file mode 100644 index 0000000..5d0db85 --- /dev/null +++ b/app/themes/palettes.js @@ -0,0 +1,39 @@ +export const THEME_DARK = 'dark'; +export const THEME_LIGHT = 'light'; + +const shared = { + accent: '#ff6b35', + pink: '#e84393', + error: '#ff4757' +}; + +export const darkPalette = { + ...shared, + bg: '#0d0d0d', + cardBg: '#161622', + inputBg: '#d2d2db', + border: '#000000', + text: '#ffffff', + muted: '#d0d0d0', + label: '#9ca0b0', + placeholder: '#070708', + surface: '#1a1a2e' +}; + +export const lightPalette = { + ...shared, + bg: '#f4f4f8', + cardBg: '#ffffff', + inputBg: '#eeeef4', + border: '#d8d8e4', + text: '#1a1a2e', + muted: '#6c7086', + label: '#4a4a5a', + placeholder: '#b0b0c0', + surface: '#e8e8f0' +}; + +export const paletteToCSSVars = (palette) => + Object.entries(palette) + .map(([key, val]) => `--musica-${key}: ${val};`) + .join('\n'); diff --git a/app/utils/apiUtils.js b/app/utils/apiUtils.js index 34b6722..5d769cf 100644 --- a/app/utils/apiUtils.js +++ b/app/utils/apiUtils.js @@ -5,22 +5,37 @@ import { mapKeysDeep } from './index'; const apiClients = { github: null, - default: null + default: null, + auth: null, + music: null }; export const getApiClient = (type = 'github') => apiClients[type]; export const generateApiClient = (type = 'github') => { + if (apiClients[type]) { + return apiClients[type]; + } switch (type) { - case 'github': - apiClients[type] = createApiClientWithTransForm(process.env.NEXT_PUBLIC_GITHUB_URL); + case 'auth': + apiClients[type] = createApiClientWithTransForm('http://localhost:9000'); + return apiClients[type]; + case 'music': + apiClients[type] = createApiClientWithTransForm('http://localhost:9000', { skipRequestTransform: true }); return apiClients[type]; default: - apiClients.default = createApiClientWithTransForm(process.env.NEXT_PUBLIC_GITHUB_URL); - return apiClients.default; + apiClients[type] = createApiClientWithTransForm(process.env.NEXT_PUBLIC_GITHUB_URL); + return apiClients[type]; } }; -export const createApiClientWithTransForm = (baseURL) => { +export const setAuthHeader = (type, token) => { + const client = apiClients[type]; + if (client) { + client.setHeader('Authorization', `Bearer ${token}`); + } +}; + +export const createApiClientWithTransForm = (baseURL, options = {}) => { const api = create({ baseURL, headers: { 'Content-Type': 'application/json' } @@ -33,12 +48,14 @@ export const createApiClientWithTransForm = (baseURL) => { return response; }); - api.addRequestTransform((request) => { - const { data } = request; - if (data) { - request.data = mapKeysDeep(data, (keys) => snakeCase(keys)); - } - return request; - }); + if (!options.skipRequestTransform) { + api.addRequestTransform((request) => { + const { data } = request; + if (data) { + request.data = mapKeysDeep(data, (keys) => snakeCase(keys)); + } + return request; + }); + } return api; }; diff --git a/app/utils/authStorage.js b/app/utils/authStorage.js new file mode 100644 index 0000000..76db406 --- /dev/null +++ b/app/utils/authStorage.js @@ -0,0 +1,24 @@ +const TOKEN_KEY = 'musica_access_token'; + +const isBrowser = () => typeof window !== 'undefined'; + +export const getStoredToken = () => { + if (!isBrowser()) { + return null; + } + return localStorage.getItem(TOKEN_KEY); +}; + +export const setStoredToken = (token) => { + if (!isBrowser()) { + return; + } + localStorage.setItem(TOKEN_KEY, token); +}; + +export const clearStoredToken = () => { + if (!isBrowser()) { + return; + } + localStorage.removeItem(TOKEN_KEY); +}; diff --git a/app/utils/formatDuration.js b/app/utils/formatDuration.js new file mode 100644 index 0000000..ddc990c --- /dev/null +++ b/app/utils/formatDuration.js @@ -0,0 +1,6 @@ +export const formatDuration = (ms) => { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +}; diff --git a/app/utils/tests/authStorage.test.js b/app/utils/tests/authStorage.test.js new file mode 100644 index 0000000..0eeb56d --- /dev/null +++ b/app/utils/tests/authStorage.test.js @@ -0,0 +1,32 @@ +import { + getStoredToken, + setStoredToken, + clearStoredToken +} from '../authStorage' + +describe('authStorage', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('should return null when no token is stored', () => { + expect(getStoredToken()).toBeNull() + }) + + it('should store and retrieve a token', () => { + setStoredToken('test-token-123') + expect(getStoredToken()).toBe('test-token-123') + }) + + it('should clear the stored token', () => { + setStoredToken('test-token-123') + clearStoredToken() + expect(getStoredToken()).toBeNull() + }) + + it('should overwrite an existing token', () => { + setStoredToken('old-token') + setStoredToken('new-token') + expect(getStoredToken()).toBe('new-token') + }) +}) diff --git a/app/utils/tests/formatDuration.test.js b/app/utils/tests/formatDuration.test.js new file mode 100644 index 0000000..396fc8f --- /dev/null +++ b/app/utils/tests/formatDuration.test.js @@ -0,0 +1,19 @@ +import { formatDuration } from '../formatDuration' + +describe('formatDuration', () => { + it('should format milliseconds to m:ss', () => { + expect(formatDuration(210000)).toBe('3:30') + }) + + it('should pad seconds with leading zero', () => { + expect(formatDuration(65000)).toBe('1:05') + }) + + it('should handle zero', () => { + expect(formatDuration(0)).toBe('0:00') + }) + + it('should handle exact minutes', () => { + expect(formatDuration(120000)).toBe('2:00') + }) +}) diff --git a/app/utils/tests/themeStorage.test.js b/app/utils/tests/themeStorage.test.js new file mode 100644 index 0000000..e75ee78 --- /dev/null +++ b/app/utils/tests/themeStorage.test.js @@ -0,0 +1,22 @@ +import { getStoredTheme, setStoredTheme } from '../themeStorage' + +describe('themeStorage', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('should return null when no theme is stored', () => { + expect(getStoredTheme()).toBeNull() + }) + + it('should store and retrieve a theme', () => { + setStoredTheme('light') + expect(getStoredTheme()).toBe('light') + }) + + it('should overwrite an existing theme', () => { + setStoredTheme('dark') + setStoredTheme('light') + expect(getStoredTheme()).toBe('light') + }) +}) diff --git a/app/utils/tests/withAuth.test.js b/app/utils/tests/withAuth.test.js new file mode 100644 index 0000000..c2cd43e --- /dev/null +++ b/app/utils/tests/withAuth.test.js @@ -0,0 +1,40 @@ +import React from 'react' +import { render } from '@testing-library/react' +import withAuth from '../withAuth' +import * as authStorage from '../authStorage' + +const mockReplace = jest.fn() + +jest.mock('next/router', () => ({ + useRouter: () => ({ replace: mockReplace }) +})) + +describe('withAuth HOC', () => { + const TestComponent = () => ( +
Protected Content
+ ) + const WrappedComponent = withAuth(TestComponent) + + beforeEach(() => { + mockReplace.mockClear() + }) + + it('should render the wrapped component when token exists', () => { + jest.spyOn(authStorage, 'getStoredToken').mockReturnValue('valid-token') + const { getByTestId } = render() + expect(getByTestId('protected')).toBeTruthy() + authStorage.getStoredToken.mockRestore() + }) + + it('should redirect to /login when no token exists', () => { + jest.spyOn(authStorage, 'getStoredToken').mockReturnValue(null) + const { queryByTestId } = render() + expect(queryByTestId('protected')).toBeNull() + expect(mockReplace).toHaveBeenCalledWith('/login') + authStorage.getStoredToken.mockRestore() + }) + + it('should set the correct displayName', () => { + expect(WrappedComponent.displayName).toBe('withAuth(TestComponent)') + }) +}) diff --git a/app/utils/themeStorage.js b/app/utils/themeStorage.js new file mode 100644 index 0000000..89533c7 --- /dev/null +++ b/app/utils/themeStorage.js @@ -0,0 +1,17 @@ +const THEME_KEY = 'musica_theme'; + +const isBrowser = () => typeof window !== 'undefined'; + +export const getStoredTheme = () => { + if (!isBrowser()) { + return null; + } + return localStorage.getItem(THEME_KEY); +}; + +export const setStoredTheme = (theme) => { + if (!isBrowser()) { + return; + } + localStorage.setItem(THEME_KEY, theme); +}; diff --git a/app/utils/withAuth.js b/app/utils/withAuth.js new file mode 100644 index 0000000..6e21774 --- /dev/null +++ b/app/utils/withAuth.js @@ -0,0 +1,29 @@ +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { getStoredToken } from './authStorage'; + +const withAuth = (WrappedComponent) => { + const AuthGuard = (props) => { + const router = useRouter(); + const [isReady, setIsReady] = useState(false); + + useEffect(() => { + const token = getStoredToken(); + if (!token) { + router.replace('/login'); + } else { + setIsReady(true); + } + }, []); + + if (!isReady) { + return null; + } + return ; + }; + + AuthGuard.displayName = `withAuth(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; + return AuthGuard; +}; + +export default withAuth; diff --git a/environments/.env.development b/environments/.env.development index b0ede23..5d70199 100644 --- a/environments/.env.development +++ b/environments/.env.development @@ -1 +1,3 @@ NEXT_PUBLIC_GITHUB_URL=https://api.github.com/ +NEXT_PUBLIC_POSTHOG_KEY=phc_s5dpMW7DvBDzwFpo0dWrj59N2iFi6aj59yn4bW8gmP8 +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com \ No newline at end of file diff --git a/environments/.env.production b/environments/.env.production index b0ede23..5d70199 100644 --- a/environments/.env.production +++ b/environments/.env.production @@ -1 +1,3 @@ NEXT_PUBLIC_GITHUB_URL=https://api.github.com/ +NEXT_PUBLIC_POSTHOG_KEY=phc_s5dpMW7DvBDzwFpo0dWrj59N2iFi6aj59yn4bW8gmP8 +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com \ No newline at end of file diff --git a/environments/.env.qa b/environments/.env.qa index b0ede23..5d70199 100644 --- a/environments/.env.qa +++ b/environments/.env.qa @@ -1 +1,3 @@ NEXT_PUBLIC_GITHUB_URL=https://api.github.com/ +NEXT_PUBLIC_POSTHOG_KEY=phc_s5dpMW7DvBDzwFpo0dWrj59N2iFi6aj59yn4bW8gmP8 +NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com \ No newline at end of file diff --git a/package.json b/package.json index e228c22..04242ee 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "*.js": [ "npm run lint:eslint:fix", "git add --force", - "jest --findRelatedTests --passWithNoTests $STAGED_FILES" + "jest --findRelatedTests -u --passWithNoTests $STAGED_FILES" ], "*.json": [ "prettier --write", diff --git a/pages/_app.js b/pages/_app.js index bc444c8..2dec155 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -1,22 +1,33 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { IntlProvider } from 'react-intl'; -import { ThemeProvider } from 'styled-components'; +import { ThemeProvider as SCThemeProvider } from 'styled-components'; import colors from '@themes/colors'; import globalStyle from '@app/global-styles'; import { Global } from '@emotion/react'; import { translationMessages, DEFAULT_LOCALE } from '@app/i18n'; import { wrapper } from '@app/configureStore'; +import { getStoredToken } from '@utils/authStorage'; +import { setAuthHeader } from '@utils/apiUtils'; +import { ThemeProvider } from '@app/contexts/ThemeContext'; import PropTypes from 'prop-types'; -const theme = { - colors -}; + +const scTheme = { colors }; const MyApp = ({ Component, pageProps }) => { + useEffect(() => { + const token = getStoredToken(); + if (token) { + setAuthHeader('music', token); + } + }, []); + return ( - - - + + + + + ); diff --git a/pages/_document.js b/pages/_document.js index 282aeff..c1d7ad9 100644 --- a/pages/_document.js +++ b/pages/_document.js @@ -8,7 +8,14 @@ const MyDocument = ({ localeDataScript, locale, styles }) => { return ( - + + + + +