├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ ├── logo.svg │ └── logo_single.png ├── favicon.ico └── vercel.svg ├── src ├── components │ ├── ArtistItem │ │ └── ArtistItem.tsx │ ├── ControlButton │ │ └── ControlButton.tsx │ ├── GenreItem │ │ └── GenreItem.tsx │ ├── Greeting │ │ └── Greeting.tsx │ ├── Header │ │ ├── CollectionMenu │ │ │ └── CollectionMenu.tsx │ │ ├── Header.tsx │ │ └── SearchBar │ │ │ └── SearchBar.tsx │ ├── MediaItem │ │ └── MediaItem.tsx │ ├── MediaSection │ │ └── MediaSection.tsx │ ├── Navbar │ │ ├── Navbar.tsx │ │ ├── NavbarItem.tsx │ │ └── index.ts │ ├── NowPlaying │ │ └── NowPlayingCover.tsx │ ├── PlayButton │ │ └── PlayButton.tsx │ ├── PlayerBar │ │ ├── Controls.tsx │ │ ├── NowPlaying.tsx │ │ ├── Player.tsx │ │ ├── PlayerBar.tsx │ │ └── index.ts │ ├── Section │ │ └── Section.tsx │ ├── SplashItem │ │ └── SplashItem.tsx │ ├── UI │ │ ├── CSlider.tsx │ │ ├── ElasticTabs │ │ │ └── ElasticTabs.tsx │ │ ├── NormalLink.tsx │ │ └── index.ts │ └── UserProfile │ │ └── UserProfile.tsx ├── contexts │ └── useNowPlaying.tsx ├── data │ ├── dummy.ts │ └── navbarItems.tsx ├── hooks │ ├── useElasticTabs.ts │ └── useFullscreen.ts ├── layouts │ ├── CollectionLayout.tsx │ └── MainLayout.tsx ├── models │ ├── Artist.ts │ ├── Genre.ts │ └── MediaItem.ts ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── hello.ts │ ├── collection │ │ ├── albums │ │ │ └── index.tsx │ │ ├── artists │ │ │ └── index.tsx │ │ ├── playlists │ │ │ └── index.tsx │ │ └── podcasts │ │ │ └── index.tsx │ ├── index.tsx │ └── search │ │ ├── [keyword] │ │ └── index.tsx │ │ └── index.tsx ├── store │ ├── features │ │ ├── menu.slice.ts │ │ └── nowPlaying.slice.ts │ ├── hooks.ts │ └── store.ts ├── styles │ └── globals.css ├── theming │ ├── GlobalFonts.tsx │ ├── fonts │ │ └── Gotham-Font │ │ │ ├── Fonts Magazine.txt │ │ │ ├── Gotham-Black.otf │ │ │ ├── Gotham-Bold.otf │ │ │ ├── Gotham-BookItalic.otf │ │ │ ├── Gotham-Light.otf │ │ │ ├── Gotham-Thin.otf │ │ │ ├── Gotham-ThinItalic.otf │ │ │ ├── Gotham-UltraItalic.otf │ │ │ ├── Gotham-XLight.otf │ │ │ ├── Gotham-XLightItalic.otf │ │ │ ├── GothamBold.ttf │ │ │ ├── GothamBoldItalic.ttf │ │ │ ├── GothamBook.ttf │ │ │ ├── GothamBookItalic.ttf │ │ │ ├── GothamLight.ttf │ │ │ ├── GothamLightItalic.ttf │ │ │ ├── GothamMedium.ttf │ │ │ ├── GothamMediumItalic.ttf │ │ │ └── GothamMedium_1.ttf │ └── theme.ts └── utils │ ├── breakpoints.ts │ └── styles.ts ├── tailwind.config.js └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/Thumbs.db": true, 9 | "**/node_modules": true 10 | } 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gamekohl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔖 Spotify Clone using React, NextJS and Redux 2 | This repository contains the source code of a **Spotify clone**.
3 | It's not an exact copy of the original Spotify but the main aspects are as close as possible copied to deliver the typical Spotify feeling. 4 | Check it out and let me know your thoughts. If you like it make sure to give it a ⭐ Star! 5 | 6 | **NOTE:** The project is still in development and I will regularly make updates to the repo. 🚧 7 | 8 | ## 🔭 Demo 9 | Use the link down below to check out the clone: 10 | 11 | [Demo](https://react-spotify-tau.vercel.app) 12 | 13 | 14 | ## 📖 Tech Stack 15 | - [React](https://github.com/facebook/react) ∙ [Redux](https://github.com/reduxjs/redux) 16 | - Client & Data management 17 | - [NextJS](https://github.com/vercel/next.js) 18 | - SSR, SSG, Routing 19 | - [Mantine](https://github.com/mantinedev/mantine) 20 | - Styling, Layout, Theme, Components 21 | 22 | ## 💡 Chrome Lighthouse Report 23 | ![Report Summary](https://user-images.githubusercontent.com/108492240/183624931-e7ac2ca1-b2bb-4727-b002-e57f8285f673.png) 24 | ![Metrics](https://user-images.githubusercontent.com/108492240/183624967-53e9928e-2401-4141-b681-9ddf9cf2fe16.png) 25 | 26 | ## 🚀 Deployment 27 | The deployment is done using Vercel. 28 | 29 | ## ✏️ Author 30 | Gamekohl 31 | 32 | ## 🔑 License 33 | This project is licensed under the MIT License - see the LICENSE file for details 34 | 35 | 36 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | images: { 6 | domains: ['i.scdn.co', 't.scdn.co', 'images.unsplash.com', 'charts-images.scdn.co', 'avatars.dicebear.com'] 7 | } 8 | } 9 | 10 | module.exports = nextConfig 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-spotify", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@emotion/react": "^11.10.0", 13 | "@emotion/server": "^11.10.0", 14 | "@mantine/core": "^5.0.2", 15 | "@mantine/hooks": "^5.0.2", 16 | "@mantine/next": "^5.0.2", 17 | "@reduxjs/toolkit": "^1.8.3", 18 | "@types/lodash": "^4.14.182", 19 | "fast-average-color": "^7.1.0", 20 | "framer-motion": "^5.6.0", 21 | "lodash": "^4.17.21", 22 | "next": "12.2.3", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "react-redux": "^8.0.2", 26 | "sass": "^1.45.1", 27 | "tabler-icons-react": "^1.54.0" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "18.6.3", 31 | "@types/react": "18.0.15", 32 | "@types/react-dom": "18.0.6", 33 | "autoprefixer": "^10.4.8", 34 | "eslint": "8.20.0", 35 | "eslint-config-next": "12.2.3", 36 | "postcss": "^8.4.14", 37 | "tailwindcss": "^3.1.7", 38 | "typescript": "4.7.4" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/logo.svg: -------------------------------------------------------------------------------- 1 | Spotify -------------------------------------------------------------------------------- /public/assets/logo_single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/public/assets/logo_single.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/ArtistItem/ArtistItem.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Text } from '@mantine/core'; 2 | import Image from 'next/image'; 3 | import React, { FunctionComponent } from 'react' 4 | import { Artist } from '../../models/Artist'; 5 | 6 | type AristItemProps = Artist; 7 | 8 | const useStyles = createStyles({ 9 | wrapper: { 10 | transition: 'background-color .3s ease' 11 | }, 12 | imgWrapper: { 13 | backgroundColor: '#333', 14 | boxShadow: '0 8px 24px rgb(0 0 0 / 50%)', 15 | position: 'relative', 16 | paddingBottom: '100%', 17 | width: '100%', 18 | borderRadius: '50%', 19 | overflow: 'hidden' 20 | } 21 | }); 22 | 23 | const ArtistItem: FunctionComponent = (item) => { 24 | const { classes, cx } = useStyles(); 25 | 26 | return ( 27 |
29 |
30 |
31 |
32 | {item.name} 33 |
34 |
35 |
36 | {item.name} 37 | Artist 38 |
39 |
40 |
41 | ) 42 | } 43 | 44 | export default ArtistItem -------------------------------------------------------------------------------- /src/components/ControlButton/ControlButton.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from '@mantine/core'; 2 | import React, { FunctionComponent, HTMLProps, ReactNode } from 'react' 3 | 4 | type ControlButtonProps = { 5 | icon: ReactNode; 6 | tooltipLabel?: string; 7 | } 8 | const ControlButton: FunctionComponent> = ({ icon, tooltipLabel, ...props }) => { 9 | return ( 10 | <> 11 | {!!tooltipLabel ? ( 12 | 13 |
14 | {icon} 15 |
16 |
17 | ) : ( 18 |
19 | {icon} 20 |
21 | ) 22 | } 23 | 24 | ); 25 | }; 26 | 27 | export default ControlButton; -------------------------------------------------------------------------------- /src/components/GenreItem/GenreItem.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Text } from '@mantine/core' 2 | import Image from 'next/image' 3 | import React, { FunctionComponent } from 'react' 4 | 5 | type GenreItemProps = { 6 | title: string; 7 | img: string; 8 | bg: string; 9 | smaller?: boolean; 10 | } 11 | 12 | const useStyles = createStyles((theme, { bg, smaller }: { bg: string, smaller: boolean }) => ({ 13 | wrapper: { 14 | backgroundColor: bg, 15 | transition: 'filter .3s ease', 16 | 17 | [smaller && '&:after']: { 18 | display: 'block', 19 | content: '""', 20 | paddingBottom: '100%' 21 | }, 22 | 23 | '&:hover': { 24 | filter: 'brightness(110%)' 25 | } 26 | } 27 | })) 28 | 29 | const GenreItem: FunctionComponent = ({ img, title, bg, ...rest }) => { 30 | const { smaller = false } = rest; 31 | const { classes, cx } = useStyles({ bg, smaller }); 32 | 33 | return ( 34 |
39 | {title} 40 |
41 | {title} 47 |
48 |
49 | ) 50 | } 51 | 52 | export default GenreItem -------------------------------------------------------------------------------- /src/components/Greeting/Greeting.tsx: -------------------------------------------------------------------------------- 1 | import { Text, TextProps } from '@mantine/core'; 2 | import React, { FunctionComponent, useEffect, useState } from 'react' 3 | 4 | const Greeting: FunctionComponent = ({ ...props }) => { 5 | const [greeting, setGreeting] = useState(''); 6 | 7 | useEffect(() => { 8 | const hours = new Date().getHours(); 9 | 10 | if (hours < 12) { 11 | setGreeting('Good morning'); 12 | } else if (hours >= 12 && hours < 18) { 13 | setGreeting('Hello'); 14 | } else { 15 | setGreeting('Good evening'); 16 | } 17 | }, []); 18 | 19 | 20 | return ( 21 | {greeting} 22 | ) 23 | } 24 | 25 | export default Greeting; -------------------------------------------------------------------------------- /src/components/Header/CollectionMenu/CollectionMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler } from 'react' 2 | import { motion } from 'framer-motion'; 3 | import { Button, createStyles } from '@mantine/core'; 4 | import Link from 'next/link'; 5 | import { useRouter } from 'next/router'; 6 | import ElasticTabs from '../../UI/ElasticTabs/ElasticTabs'; 7 | import { useMediaQuery } from '@mantine/hooks'; 8 | import { Breakpoint, maxWidth } from '../../../utils/breakpoints'; 9 | 10 | const useStyles = createStyles((theme) => ({ 11 | buttonRoot: { 12 | '&:hover': { 13 | backgroundColor: 'unset' 14 | } 15 | }, 16 | active: { 17 | backgroundColor: `${theme.colors.spotifyAccent[6]} !important` 18 | } 19 | })); 20 | 21 | const buttons = [ 22 | { 23 | link: '/collection/playlists', 24 | title: 'Playlists' 25 | }, 26 | { 27 | link: '/collection/podcasts', 28 | title: 'Podcasts' 29 | }, 30 | { 31 | link: '/collection/artists', 32 | title: 'Artists' 33 | }, 34 | { 35 | link: '/collection/albums', 36 | title: 'Albums' 37 | }, 38 | ]; 39 | 40 | const CollectionMenu = () => { 41 | const router = useRouter(); 42 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 43 | const { classes, cx } = useStyles(); 44 | 45 | return ( 46 | 54 | 59 | {(onHover: MouseEventHandler) => ( 60 | <> 61 | {buttons.map((item, key) => ( 62 | 63 | 73 | 74 | ))} 75 | 76 | )} 77 | 78 | 79 | ) 80 | } 81 | 82 | export default CollectionMenu -------------------------------------------------------------------------------- /src/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { Menu2 } from 'tabler-icons-react' 3 | import UserProfile from '../UserProfile/UserProfile' 4 | import { useAppDispatch } from '../../store/hooks'; 5 | import { toggleMenu } from '../../store/features/menu.slice'; 6 | import { useMediaQuery } from '@mantine/hooks'; 7 | import { Breakpoint, maxWidth } from '../../utils/breakpoints'; 8 | import SearchBar from './SearchBar/SearchBar'; 9 | import CollectionMenu from './CollectionMenu/CollectionMenu'; 10 | 11 | const Header = () => { 12 | const dispatch = useAppDispatch(); 13 | const router = useRouter(); 14 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 15 | 16 | const openMenu = () => { 17 | dispatch(toggleMenu()); 18 | }; 19 | 20 | return ( 21 |
22 | {sm ? ( 23 |
24 | 25 |
26 | ) :
} 27 | {(!sm && new RegExp('\/collection/.*', 'g').test(router.pathname)) && } 28 | {new RegExp('\/search.*', 'g').test(router.pathname) && } 29 | 30 |
31 | ) 32 | } 33 | 34 | export default Header -------------------------------------------------------------------------------- /src/components/Header/SearchBar/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, TextInput } from '@mantine/core' 2 | import React, { ChangeEvent, useEffect, useState } from 'react' 3 | import { Search } from 'tabler-icons-react' 4 | import { motion } from 'framer-motion'; 5 | import { useRouter } from 'next/router'; 6 | import { useDebouncedValue, useFocusWithin, useMediaQuery } from '@mantine/hooks'; 7 | import { Breakpoint, maxWidth } from '../../../utils/breakpoints'; 8 | 9 | const useStyles = createStyles((theme, { xs, focused }: { xs: boolean, focused: boolean }) => ({ 10 | root: { 11 | flex: `0 1 ${xs && !focused ? '0' : '364px'}`, 12 | transition: 'flex .3s ease' 13 | }, 14 | input: { 15 | width: xs && !focused ? '0' : '100%', 16 | borderRadius: '500px', 17 | height: '40px', 18 | padding: '6px 48px', 19 | backgroundColor: '#fff', 20 | color: '#000', 21 | transition: 'all .3s ease', 22 | willChange: 'width' 23 | }, 24 | icon: { 25 | width: '40px' 26 | } 27 | })); 28 | 29 | const SearchBar = () => { 30 | const router = useRouter(); 31 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 32 | const xs = useMediaQuery(maxWidth(Breakpoint.xs)); 33 | const { ref, focused } = useFocusWithin(); 34 | const { classes, cx } = useStyles({ xs, focused }); 35 | const [value, setValue] = useState(''); 36 | const [debouncedVal] = useDebouncedValue(value, 200); 37 | 38 | useEffect(() => { 39 | router.push(`/search/${debouncedVal}`); 40 | }, [debouncedVal]); 41 | 42 | const handleInputChange = (event: ChangeEvent) => { 43 | setValue(event.target.value); 44 | } 45 | 46 | return ( 47 | 51 | } 62 | /> 63 | 64 | ) 65 | } 66 | 67 | export default SearchBar -------------------------------------------------------------------------------- /src/components/MediaItem/MediaItem.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Text } from '@mantine/core' 2 | import { useHover } from '@mantine/hooks'; 3 | import Image from 'next/image'; 4 | import React, { FunctionComponent } from 'react' 5 | import PlayButton from '../PlayButton/PlayButton'; 6 | import { motion } from 'framer-motion'; 7 | import { MediaItem } from '../../models/MediaItem'; 8 | import { useAppSelector } from '../../store/hooks'; 9 | import { selectPlaying } from '../../store/features/nowPlaying.slice'; 10 | 11 | type MediaItemProps = MediaItem; 12 | 13 | const useStyles = createStyles({ 14 | wrapper: { 15 | transition: 'background-color .3s ease' 16 | }, 17 | imgWrapper: { 18 | backgroundColor: '#333', 19 | boxShadow: '0 8px 24px rgb(0 0 0 / 50%)', 20 | position: 'relative', 21 | paddingBottom: '100%', 22 | width: '100%', 23 | borderRadius: '6px' 24 | } 25 | }); 26 | 27 | const MediaItem: FunctionComponent = (item) => { 28 | const { playing, id: playingId } = useAppSelector(selectPlaying); 29 | const { hovered, ref } = useHover(); 30 | const { classes, cx } = useStyles(); 31 | 32 | const isPlaying = playing && playingId === item.id; 33 | 34 | return ( 35 |
38 |
39 |
40 |
41 | {item.title} 42 |
43 | 48 | 49 | 50 |
51 |
52 | {item.title} 53 | {item.interpreter} 54 |
55 |
56 |
57 | ) 58 | } 59 | 60 | export default MediaItem -------------------------------------------------------------------------------- /src/components/MediaSection/MediaSection.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Text } from '@mantine/core'; 2 | import React, { FunctionComponent, ReactNode } from 'react' 3 | import { Breakpoint, maxWidth, minWidth } from '../../utils/breakpoints'; 4 | import { NormalLink } from '../UI'; 5 | 6 | type MediaSectionProps = { 7 | title: string; 8 | link: string; 9 | withLink?: boolean; 10 | } 11 | 12 | const useStyles = createStyles({ 13 | wrapper: { 14 | '--column-count': 8, 15 | gridAutoRows: 0, 16 | overflowY: 'hidden', 17 | gridTemplateRows: '1fr', 18 | gridGap: '0 24px', 19 | display: 'grid', 20 | gridTemplateColumns: 'repeat(var(--column-count), minmax(0, 1fr))', 21 | 22 | [`@media ${maxWidth(Breakpoint.sm)}`]: { 23 | '--column-count': 2, 24 | }, 25 | [`@media ${minWidth(Breakpoint.sm)}`]: { 26 | '--column-count': 3, 27 | }, 28 | [`@media ${minWidth(Breakpoint.md)}`]: { 29 | '--column-count': 3, 30 | }, 31 | [`@media ${minWidth(Breakpoint.lg)}`]: { 32 | '--column-count': 4, 33 | }, 34 | [`@media ${minWidth(Breakpoint.xl)}`]: { 35 | '--column-count': 5, 36 | }, 37 | [`@media ${minWidth(Breakpoint.xxl)}`]: { 38 | '--column-count': 8, 39 | }, 40 | } 41 | }) 42 | 43 | const MediaSection: FunctionComponent = ({ title, link, children, ...rest }) => { 44 | const { withLink = true } = rest; 45 | const { classes } = useStyles(); 46 | 47 | return ( 48 |
49 |
50 | {withLink ? ( 51 | <> 52 | 53 | {title} 54 | 55 | 56 | See All 57 | 58 | 59 | ) : ( 60 | {title} 61 | )} 62 |
63 |
{children}
64 |
65 | ) 66 | } 67 | 68 | export default MediaSection -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Text, Tooltip } from '@mantine/core' 2 | import Image from 'next/image'; 3 | import Link from 'next/link'; 4 | import { useRouter } from 'next/router'; 5 | import React, { useEffect, useState } from 'react' 6 | import { navbarItems } from '../../data/navbarItems'; 7 | import NavbarItem from './NavbarItem'; 8 | import { Download, ChevronsLeft, ChevronsRight } from 'tabler-icons-react'; 9 | import { motion } from 'framer-motion'; 10 | import { useNowPlayingContext } from '../../contexts/useNowPlaying'; 11 | import NowPlayingCover from '../NowPlaying/NowPlayingCover'; 12 | import { useMediaQuery } from '@mantine/hooks'; 13 | import { Breakpoint, maxWidth } from '../../utils/breakpoints'; 14 | import { useAppDispatch } from '../../store/hooks'; 15 | import { closeMenu } from '../../store/features/menu.slice'; 16 | 17 | const useStlyes = createStyles((theme, { isCollapsed }: { isCollapsed: boolean }) => ({ 18 | wrapper: { 19 | width: isCollapsed ? '75px' : '226px', 20 | maxWidth: '226px', 21 | gridArea: 'nav-bar', 22 | backgroundColor: '#000', 23 | willChange: 'width', 24 | transition: 'width 350ms cubic-bezier(0.075, 0.82, 0.165, 1)' 25 | }, 26 | logo: { 27 | 'svg': { 28 | height: '40px', 29 | maxWidth: '131px', 30 | width: '100%' 31 | } 32 | }, 33 | navbar: { 34 | [isCollapsed ? 'display' : '']: 'flex', 35 | [isCollapsed ? 'justifyContent' : '']: 'center' 36 | } 37 | })); 38 | 39 | const Navbar = () => { 40 | const router = useRouter(); 41 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 42 | const dispatch = useAppDispatch(); 43 | const [isCollapsed, setIsCollapsed] = useState(false); 44 | const { classes, cx } = useStlyes({ isCollapsed }); 45 | const { isMinimized } = useNowPlayingContext(); 46 | 47 | useEffect(() => { 48 | const closeMenuOnNavigate = () => dispatch(closeMenu()); 49 | 50 | router.events.on('routeChangeStart', closeMenuOnNavigate); 51 | 52 | return () => router.events.off('routeChangeStart', closeMenuOnNavigate); 53 | }, []); 54 | 55 | const toggleNavbar = () => { 56 | setIsCollapsed(!isCollapsed); 57 | } 58 | 59 | const getItem = ({ url, supressLink, icon, title, onClick }: any) => ( 60 | <> 61 | {!isCollapsed ? ( 62 |
63 | 64 | {icon} 65 | {title} 66 | 67 |
68 | ) : ( 69 | 70 |
71 | 72 | {icon} 73 | 74 |
75 |
76 | )} 77 | 78 | ) 79 | 80 | const getSlicedItems = (start: number, end?: number) => ( 81 | <> 82 | {navbarItems.slice(start, end).map((item, key) => ( 83 |
84 | 85 | 86 | {getItem(item)} 87 | 88 | 89 |
90 | ))} 91 | 92 | ) 93 | 94 | return ( 95 |
96 | 110 | 119 | {!sm && ( 120 | <> 121 |
122 | {getItem({ 123 | url: '', 124 | title: 'Install app', 125 | icon: 126 | })} 127 | {getItem({ 128 | url: '', 129 | title: isCollapsed ? 'Expand' : 'Collapse', 130 | icon: isCollapsed ? : , 131 | onClick: toggleNavbar 132 | })} 133 |
134 | 135 | 136 | 137 | 138 | )} 139 |
140 | ) 141 | } 142 | 143 | export default Navbar -------------------------------------------------------------------------------- /src/components/Navbar/NavbarItem.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles } from '@mantine/core' 2 | import React, { FunctionComponent, ReactNode } from 'react' 3 | 4 | const useStyles = createStyles((theme, { active }: { active: boolean }) => ({ 5 | wrapper: { 6 | color: active ? '#fff' : '#b9b9b9', 7 | height: '40px', 8 | transition: 'color 150ms ease-in-out', 9 | '&:hover': { 10 | color: '#fff' 11 | } 12 | } 13 | })) 14 | 15 | type NavbarItemProps = { 16 | active: boolean; 17 | } 18 | 19 | const NavbarItem: FunctionComponent = ({ children, active }) => { 20 | const { classes, cx } = useStyles({ active }); 21 | 22 | return ( 23 |
24 | {children} 25 |
26 | ) 27 | } 28 | 29 | export default NavbarItem -------------------------------------------------------------------------------- /src/components/Navbar/index.ts: -------------------------------------------------------------------------------- 1 | import Navbar from "./Navbar"; 2 | 3 | export { Navbar } -------------------------------------------------------------------------------- /src/components/NowPlaying/NowPlayingCover.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles } from '@mantine/core'; 2 | import React, { FunctionComponent } from 'react' 3 | import { useNowPlayingContext } from '../../contexts/useNowPlaying'; 4 | import { ChevronDown, ChevronUp } from 'tabler-icons-react'; 5 | import { useAppSelector } from '../../store/hooks'; 6 | import { selectNowPlayingMedia } from '../../store/features/nowPlaying.slice'; 7 | import Image from 'next/image'; 8 | 9 | const useStyles = createStyles({ 10 | wrapper: { 11 | '&:hover': { 12 | '& > div': { 13 | display: 'flex' 14 | } 15 | } 16 | }, 17 | }) 18 | 19 | type NowPlayingCoverProps = { 20 | small?: boolean; 21 | } 22 | 23 | const NowPlayingCover: FunctionComponent = (props) => { 24 | const { small = false } = props; 25 | const { classes, cx } = useStyles(); 26 | const { setIsMinimized, isMinimized } = useNowPlayingContext(); 27 | const media = useAppSelector(selectNowPlayingMedia); 28 | 29 | const toggleMinimized = () => { 30 | setIsMinimized(!isMinimized); 31 | } 32 | 33 | return ( 34 |
35 |
36 | {!small ? ( 37 | 38 | ) : ( 39 | 40 | )} 41 |
42 | NowPlaying 43 |
44 | ) 45 | } 46 | 47 | export default NowPlayingCover; -------------------------------------------------------------------------------- /src/components/PlayButton/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles } from '@mantine/core' 2 | import React, { FunctionComponent } from 'react' 3 | import { PlayerPlay, PlayerPause } from 'tabler-icons-react' 4 | import { motion } from 'framer-motion' 5 | import { MediaItem } from '../../models/MediaItem'; 6 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 7 | import { selectPlaying, setNowPlayingMedia, setPlaying } from '../../store/features/nowPlaying.slice'; 8 | 9 | const useStyles = createStyles(theme => ({ 10 | wrapper: { 11 | backgroundColor: theme.colors.spotifyAccent[5], 12 | boxShadow: '0 8px 8px rgb(0 0 0 / 30%)' 13 | } 14 | })); 15 | 16 | const PlayButton: FunctionComponent = ({ id, title, interpreter, img }) => { 17 | const dispatch = useAppDispatch(); 18 | const { id: playingId, playing } = useAppSelector(selectPlaying); 19 | const { classes, cx } = useStyles(); 20 | 21 | const handleClick = () => { 22 | if (playingId === id) { 23 | dispatch(setPlaying(!playing)) 24 | } else { 25 | dispatch(setNowPlayingMedia({ id, title, interpreter, img })); 26 | } 27 | } 28 | 29 | return ( 30 | 31 | 46 | 47 | ) 48 | } 49 | 50 | export default PlayButton -------------------------------------------------------------------------------- /src/components/PlayerBar/Controls.tsx: -------------------------------------------------------------------------------- 1 | import { Microphone2, PlaylistAdd, DeviceDesktop, ArrowsDiagonal, ArrowsDiagonalMinimize2, Volume, Volume2, Volume3 } from 'tabler-icons-react'; 2 | import React, { useState } from 'react' 3 | import useFullscreen from '../../hooks/useFullscreen'; 4 | import ControlButton from '../ControlButton/ControlButton'; 5 | import CSlider from '../UI/CSlider'; 6 | 7 | const Controls = () => { 8 | const [value, setValue] = useState(100); 9 | const { toggleFullscreen, isFullscreen } = useFullscreen(); 10 | 11 | return ( 12 |
13 | } /> 14 | } /> 15 | } /> 16 |
17 | {value >= 50 ? : value > 0 ? : } 18 | 26 |
27 | {isFullscreen ? ( 28 | } /> 29 | ) : ( 30 | } /> 31 | )} 32 |
33 | ) 34 | } 35 | 36 | export default Controls -------------------------------------------------------------------------------- /src/components/PlayerBar/NowPlaying.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { motion, useAnimation } from 'framer-motion' 3 | import NowPlayingCover from '../NowPlaying/NowPlayingCover' 4 | import { useNowPlayingContext } from '../../contexts/useNowPlaying' 5 | import ControlButton from '../ControlButton/ControlButton' 6 | import { Heart, PictureInPicture } from 'tabler-icons-react' 7 | import { Text } from '@mantine/core' 8 | import { useAppSelector } from '../../store/hooks' 9 | import { selectNowPlayingMedia } from '../../store/features/nowPlaying.slice' 10 | 11 | const NowPlaying = () => { 12 | const controls = useAnimation(); 13 | const { isMinimized } = useNowPlayingContext(); 14 | const media = useAppSelector(selectNowPlayingMedia); 15 | 16 | useEffect(() => { 17 | animationSequence(); 18 | }, [isMinimized]); 19 | 20 | const animationSequence = async () => { 21 | if (!isMinimized) { 22 | await controls.start({ x: '-100%', transition: { duration: 0.1 } }); 23 | return await controls.start({ display: 'none' }); 24 | } else { 25 | await controls.start({ display: 'block' }) 26 | return await controls.start({ x: 0 }); 27 | } 28 | }; 29 | 30 | return ( 31 |
32 | 33 | 34 | 35 |
36 | {media?.title} 37 | {media?.interpreter} 38 |
39 |
40 |
41 | } /> 42 |
43 |
44 | } /> 45 |
46 |
47 |
48 | ) 49 | } 50 | 51 | export default NowPlaying -------------------------------------------------------------------------------- /src/components/PlayerBar/Player.tsx: -------------------------------------------------------------------------------- 1 | import React, { createElement, useState } from 'react' 2 | import { motion } from 'framer-motion' 3 | import { ArrowsShuffle, PlayerSkipBack, PlayerPlay, PlayerPause, PlayerSkipForward, Repeat } from 'tabler-icons-react'; 4 | import ControlButton from '../ControlButton/ControlButton'; 5 | import { CSlider } from '../UI'; 6 | import { useAppDispatch, useAppSelector } from '../../store/hooks'; 7 | import { selectPlaying, setPlaying } from '../../store/features/nowPlaying.slice'; 8 | import { useMediaQuery } from '@mantine/hooks'; 9 | import { Breakpoint, maxWidth } from '../../utils/breakpoints'; 10 | import { useStyles } from '../../utils/styles'; 11 | 12 | const Player = () => { 13 | const mockDuration = 435; 14 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 15 | const dispatch = useAppDispatch(); 16 | const { cx } = useStyles(); 17 | const { playing } = useAppSelector(selectPlaying); 18 | const [value, setValue] = useState(0); 19 | 20 | const togglePlaying = () => dispatch(setPlaying(!playing)); 21 | 22 | const handleOnChange = (value: number) => { 23 | setValue((value / 100) * mockDuration); 24 | } 25 | 26 | const convertDuration = (duration: number) => { 27 | const minutes = Math.floor(duration / 60); 28 | const seconds = Math.floor(duration % 60); 29 | 30 | const padTo2Digits = (num: number) => { 31 | return num.toString().padStart(2, '0'); 32 | } 33 | 34 | return `${padTo2Digits(minutes)}:${padTo2Digits(seconds)}`; 35 | } 36 | 37 | return ( 38 | 47 |
48 |
49 |
50 | } /> 51 | } /> 52 |
53 |
54 | {createElement(!playing ? PlayerPlay : PlayerPause, { 55 | fill: "currentColor", 56 | onClick: togglePlaying, 57 | size: 20 58 | })} 59 |
60 |
61 | } /> 62 | } /> 63 |
64 |
65 |
66 |
67 | {convertDuration(value)} 68 | 69 | {convertDuration(mockDuration)} 70 |
71 |
72 | ) 73 | } 74 | 75 | export default Player -------------------------------------------------------------------------------- /src/components/PlayerBar/PlayerBar.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles } from '@mantine/core' 2 | import { useMediaQuery } from '@mantine/hooks'; 3 | import dynamic from 'next/dynamic'; 4 | import React from 'react' 5 | import { Breakpoint, maxWidth } from '../../utils/breakpoints'; 6 | import Controls from './Controls'; 7 | import NowPlaying from './NowPlaying'; 8 | const Player = dynamic(() => import('./Player'), { 9 | ssr: false 10 | }); 11 | 12 | const useStyles = createStyles((theme, { sm }: { sm: boolean }) => ({ 13 | wrapper: { 14 | gridArea: 'now-playing-bar', 15 | backgroundColor: '#181818', 16 | width: '100%', 17 | zIndex: 4 18 | }, 19 | bar: { 20 | minWidth: sm ? '100%' : '620px', 21 | height: '91px', 22 | borderTop: '1px solid #282828' 23 | } 24 | })); 25 | 26 | const PlayerBar = () => { 27 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 28 | const { classes, cx } = useStyles({ sm }); 29 | 30 | return ( 31 |
32 |
37 | {!sm && } 38 | 39 | {!sm && } 40 |
41 |
42 | ) 43 | } 44 | 45 | export default PlayerBar -------------------------------------------------------------------------------- /src/components/PlayerBar/index.ts: -------------------------------------------------------------------------------- 1 | import Controls from "./Controls"; 2 | import NowPlaying from "./NowPlaying"; 3 | import PlayerBar from "./PlayerBar"; 4 | import Player from "./Player"; 5 | 6 | export { Controls }; 7 | export { NowPlaying }; 8 | export { PlayerBar }; 9 | export { Player }; -------------------------------------------------------------------------------- /src/components/Section/Section.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Text } from '@mantine/core' 2 | import React, { FunctionComponent, ReactNode } from 'react' 3 | import { Breakpoint, maxWidth, minWidth } from '../../utils/breakpoints' 4 | 5 | type SectionProps = { 6 | title: string; 7 | } 8 | 9 | const useStyles = createStyles({ 10 | wrapper: { 11 | '--column-count': 8, 12 | gridAutoRows: 'auto', 13 | gridTemplateRows: '1fr', 14 | gridGap: '24px', 15 | display: 'grid', 16 | gridTemplateColumns: 'repeat(var(--column-count), minmax(0, 1fr))', 17 | 18 | [`@media ${maxWidth(Breakpoint.sm)}`]: { 19 | '--column-count': 2, 20 | }, 21 | [`@media ${minWidth(Breakpoint.sm)}`]: { 22 | '--column-count': 3, 23 | }, 24 | [`@media ${minWidth(Breakpoint.md)}`]: { 25 | '--column-count': 3, 26 | }, 27 | [`@media ${minWidth(Breakpoint.lg)}`]: { 28 | '--column-count': 4, 29 | }, 30 | [`@media ${minWidth(Breakpoint.xl)}`]: { 31 | '--column-count': 5, 32 | }, 33 | [`@media ${minWidth(Breakpoint.xxl)}`]: { 34 | '--column-count': 8, 35 | }, 36 | } 37 | }) 38 | 39 | const Section: FunctionComponent = ({ title, children }) => { 40 | const { classes } = useStyles(); 41 | 42 | return ( 43 |
44 |
45 | {title} 46 |
47 |
48 | {children} 49 |
50 |
51 | ) 52 | } 53 | 54 | export default Section -------------------------------------------------------------------------------- /src/components/SplashItem/SplashItem.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Text } from '@mantine/core'; 2 | import { useHover } from '@mantine/hooks'; 3 | import Image from 'next/image'; 4 | import React, { FunctionComponent, useEffect, useState } from 'react' 5 | import PlayButton from '../PlayButton/PlayButton'; 6 | import FastAverageColor from 'fast-average-color'; 7 | import { MediaItem } from '../../models/MediaItem'; 8 | import { useAppSelector } from '../../store/hooks'; 9 | import { selectPlaying } from '../../store/features/nowPlaying.slice'; 10 | const fac = new FastAverageColor(); 11 | 12 | type SplashItemProps = MediaItem & { 13 | emitAvgColor: (color: string) => void; 14 | } 15 | 16 | const useStyles = createStyles({ 17 | wrapper: { 18 | backgroundColor: 'hsla(0,0%,100%,.1)', 19 | transition: 'background-color .3s ease', 20 | 21 | '&:hover': { 22 | backgroundColor: 'hsla(0,0%,100%,.2)', 23 | } 24 | }, 25 | imgWrapper: { 26 | minWidth: '80px', 27 | minHeight: '80px', 28 | boxShadow: '0 8px 24px rgb(0 0 0 / 50%)' 29 | } 30 | }); 31 | 32 | const SplashItem: FunctionComponent = ({ emitAvgColor, ...item }) => { 33 | const { classes, cx } = useStyles(); 34 | const { hovered, ref } = useHover(); 35 | const { playing, id: playingId } = useAppSelector(selectPlaying); 36 | const [avgColor, setAvgColor] = useState(''); 37 | 38 | useEffect(() => { 39 | fac.getColorAsync(item.img).then(color => { 40 | setAvgColor(color.rgba); 41 | }); 42 | }); 43 | 44 | useEffect(() => { 45 | emitAvgColor(avgColor); 46 | }, [hovered]); 47 | 48 | const isPlaying = playing && playingId === item.id; 49 | 50 | return ( 51 |
52 |
53 | {item.title} 54 |
55 |
56 | {item.title} 57 | {(hovered || isPlaying) && } 58 |
59 |
60 | ) 61 | } 62 | 63 | export default SplashItem -------------------------------------------------------------------------------- /src/components/UI/CSlider.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Slider, SliderProps } from '@mantine/core' 2 | import { FunctionComponent } from 'react' 3 | 4 | const useStyles = createStyles((theme) => ({ 5 | sliderBar: { 6 | backgroundColor: '#fff' 7 | }, 8 | sliderThumb: { 9 | backgroundColor: '#fff', 10 | }, 11 | sliderRoot: { 12 | '&:hover': { 13 | '& .mantine-Slider-bar': { 14 | backgroundColor: theme.colors.spotifyAccent[5] 15 | } 16 | } 17 | } 18 | })); 19 | 20 | const CSlider: FunctionComponent = (props) => { 21 | const { classes } = useStyles(); 22 | 23 | return ( 24 | 32 | ) 33 | } 34 | 35 | export default CSlider -------------------------------------------------------------------------------- /src/components/UI/ElasticTabs/ElasticTabs.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, FunctionComponent } from 'react' 2 | import useElasticTabs from '../../../hooks/useElasticTabs'; 3 | 4 | type ElasticTabsProps = { 5 | backdropStyle: CSSProperties; 6 | transitionDuration?: string; 7 | transitionTimingFunction?: string; 8 | } 9 | 10 | const ElasticTabs: FunctionComponent<{ children: any } & ElasticTabsProps> = ({ children, backdropStyle, ...rest }) => { 11 | const { transitionDuration = '.5s', transitionTimingFunction = 'cubic-bezier(.75, 0, 0, 1)' } = rest; 12 | const { onMouseEnter, style } = useElasticTabs(); 13 | 14 | const onHover = onMouseEnter; 15 | 16 | return ( 17 | <> 18 |
28 | {children(onHover)} 29 | 30 | ) 31 | } 32 | 33 | export default ElasticTabs -------------------------------------------------------------------------------- /src/components/UI/NormalLink.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles } from '@mantine/core' 2 | import Link, { LinkProps } from 'next/link' 3 | import { FunctionComponent, ReactNode } from 'react' 4 | 5 | const useStyles = createStyles({ 6 | wrapper: { 7 | cursor: 'pointer', 8 | 9 | '&:hover': { 10 | textDecoration: 'underline' 11 | } 12 | } 13 | }) 14 | 15 | const NormalLink: FunctionComponent = ({ children, ...props }) => { 16 | const { classes } = useStyles(); 17 | 18 | return ( 19 |
20 | 21 | {children} 22 | 23 |
24 | ) 25 | } 26 | 27 | export default NormalLink -------------------------------------------------------------------------------- /src/components/UI/index.ts: -------------------------------------------------------------------------------- 1 | import CSlider from "./CSlider"; 2 | import NormalLink from "./NormalLink"; 3 | 4 | export { CSlider }; 5 | export { NormalLink }; -------------------------------------------------------------------------------- /src/components/UserProfile/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Menu, Text } from '@mantine/core' 2 | import { useMediaQuery } from '@mantine/hooks'; 3 | import Image from 'next/image'; 4 | import { useState } from 'react'; 5 | import { ChevronDown, ExternalLink } from 'tabler-icons-react'; 6 | import { Breakpoint, maxWidth } from '../../utils/breakpoints'; 7 | 8 | const useStyles = createStyles({ 9 | dropdown: { 10 | backgroundColor: '#282828', 11 | border: '0' 12 | }, 13 | item: { 14 | color: '#fff', 15 | fontWeight: 600 16 | } 17 | }) 18 | 19 | const UserProfile = () => { 20 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 21 | const [opened, setOpened] = useState(false); 22 | const { classes, cx } = useStyles(); 23 | 24 | return ( 25 |
26 | 38 | 39 |
47 |
48 | Profile Image 49 |
50 | {!sm && ( 51 | <> 52 | John 53 |
54 | 55 |
56 | 57 | )} 58 | 59 |
60 |
61 | 62 | }>Account 63 | Profile 64 | }>Support 65 | }>Download 66 | Log out 67 | 68 |
69 |
70 | ) 71 | } 72 | 73 | export default UserProfile -------------------------------------------------------------------------------- /src/contexts/useNowPlaying.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, FunctionComponent, ReactNode, useContext, useState } from "react"; 2 | 3 | type NowPlaying = { 4 | isMinimized: boolean; 5 | setIsMinimized: (value: boolean) => void; 6 | } 7 | 8 | const initialState: NowPlaying = { 9 | isMinimized: false, 10 | setIsMinimized: () => { } 11 | }; 12 | 13 | const NowPlayingContext = createContext(initialState); 14 | 15 | export const NowPlayingProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { 16 | const [value, setValue] = useState(initialState); 17 | 18 | const setIsMinimized = (value: boolean) => { 19 | setValue({ 20 | ...initialState, 21 | isMinimized: value 22 | }); 23 | } 24 | 25 | return ( 26 | 30 | {children} 31 | 32 | ) 33 | } 34 | 35 | export const useNowPlayingContext = () => useContext(NowPlayingContext); -------------------------------------------------------------------------------- /src/data/dummy.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "@reduxjs/toolkit"; 2 | import { Artist } from "../models/Artist"; 3 | import { Genre } from "../models/Genre"; 4 | import { MediaItem } from "../models/MediaItem"; 5 | 6 | export const dummyData: MediaItem[] = [ 7 | { 8 | id: nanoid(), 9 | img: 'https://i.scdn.co/image/ab67616d00001e022a038d3bf875d23e4aeaa84e', 10 | title: 'Happier Than Ever', 11 | interpreter: 'Billie Eilish' 12 | }, 13 | { 14 | id: nanoid(), 15 | img: 'https://i.scdn.co/image/ab67616d00001e024a767758e8ebe2443591c9fd', 16 | title: 'Watch The Throne', 17 | interpreter: 'Jay-Z' 18 | }, 19 | { 20 | id: nanoid(), 21 | img: 'https://i.scdn.co/image/ab67616d00001e026ca5c90113b30c3c43ffb8f4', 22 | title: 'The Eminem Show', 23 | interpreter: 'Eminem' 24 | }, 25 | { 26 | id: nanoid(), 27 | img: 'https://i.scdn.co/image/ab67616d00001e02c08d5fa5c0f1a834acef5100', 28 | title: 'Recovery', 29 | interpreter: 'Eminem' 30 | }, 31 | { 32 | id: nanoid(), 33 | img: 'https://i.scdn.co/image/ab67616d00001e02ea3ef7697cfd5705b8f47521', 34 | title: 'Illuminate', 35 | interpreter: 'Shawn Mendes' 36 | }, 37 | { 38 | id: nanoid(), 39 | img: 'https://i.scdn.co/image/ab67616d00001e029ad3e9959f48d513886b8933', 40 | title: 'Ride The Lightning', 41 | interpreter: 'Metallica' 42 | }, 43 | { 44 | id: nanoid(), 45 | img: 'https://i.scdn.co/image/ab67616d00001e023786aacf75e7983fdb770402', 46 | title: 'crybaby', 47 | interpreter: 'Lil Peep' 48 | }, 49 | { 50 | id: nanoid(), 51 | img: 'https://i.scdn.co/image/ab67616d00001e0255e36b0dc5b0ef008fc85319', 52 | title: 'Come Over When You\'re Sober, Pt. 2', 53 | interpreter: 'Lil Peep' 54 | }, 55 | { 56 | id: nanoid(), 57 | img: 'https://i.scdn.co/image/ab67616d00001e02668e3aca3167e6e569a9aa20', 58 | title: 'Master Of Puppets', 59 | interpreter: 'Metallica' 60 | } 61 | ] 62 | 63 | export const artists: Artist[] = [ 64 | { 65 | name: 'Billie Eilish', 66 | img: 'https://i.scdn.co/image/ab6761610000f178d8b9980db67272cb4d2c3daf' 67 | }, 68 | { 69 | name: 'Jay-Z', 70 | img: 'https://i.scdn.co/image/ab6761610000f178c75afcd5a9027f60eaebb5e4' 71 | }, 72 | { 73 | name: 'Lil Peep', 74 | img: 'https://i.scdn.co/image/ab6761610000f1786685f03de475c4efb27da3c4' 75 | }, 76 | { 77 | name: 'Eminem', 78 | img: 'https://i.scdn.co/image/ab6761610000f178a00b11c129b27a88fc72f36b' 79 | }, 80 | { 81 | name: 'Metallica', 82 | img: 'https://i.scdn.co/image/ab6761610000f1788101d13bdd630b0889acd2fd' 83 | }, 84 | { 85 | name: 'Shawn Mendes', 86 | img: 'https://i.scdn.co/image/ab6761610000f17846e7a06fa6dfefaed6a3f0db' 87 | } 88 | ] 89 | 90 | export const genres: Genre[] = [ 91 | { 92 | "bg": "rgb(39, 133, 106)", 93 | "img": "https://i.scdn.co/image/567158eb895ad26718a814345af0fc43ee785ec5", 94 | "title": "Podcasts" 95 | }, 96 | { 97 | "bg": "rgb(30, 50, 100)", 98 | "img": "https://t.scdn.co/images/ea364e99656e46a096ea1df50f581efe", 99 | "title": "Made For You" 100 | }, 101 | { 102 | "bg": "rgb(141, 103, 171)", 103 | "img": "https://charts-images.scdn.co/assets/locale_en/regional/weekly/region_global_default.jpg", 104 | "title": "Charts" 105 | }, 106 | { 107 | "bg": "rgb(232, 17, 91)", 108 | "img": "https://i.scdn.co/image/ab67706f000000027ea4d505212b9de1f72c5112", 109 | "title": "New Releases" 110 | }, 111 | { 112 | "bg": "rgb(141, 103, 171)", 113 | "img": "https://t.scdn.co/images/d0fb2ab104dc4846bdc56d72b0b0d785.jpeg", 114 | "title": "Discover" 115 | }, 116 | { 117 | "bg": "rgb(30, 50, 100)", 118 | "img": "https://t.scdn.co/images/8cfa9cb1e43a404db76eed6ad594057c", 119 | "title": "Live Events" 120 | }, 121 | { 122 | "bg": "rgb(255, 200, 100)", 123 | "img": "https://t.scdn.co/images/a2a24668f16c4e9680233a0d7d244a4b.jpeg", 124 | "title": "Summer" 125 | }, 126 | { 127 | "bg": "rgb(141, 103, 171)", 128 | "img": "https://i.scdn.co/image/ab67706f00000002aa93fe4e8c2d24fc62556cba", 129 | "title": "Mood" 130 | }, 131 | { 132 | "bg": "rgb(220, 20, 140)", 133 | "img": "https://i.scdn.co/image/ab67706f000000020377baccf69ede3cf1a26eff", 134 | "title": "Dance / Electronic" 135 | }, 136 | { 137 | "bg": "rgb(71, 125, 149)", 138 | "img": "https://i.scdn.co/image/ab67706f00000002c414e7daf34690c9f983f76e", 139 | "title": "Chill" 140 | }, 141 | { 142 | "bg": "rgb(180, 155, 200)", 143 | "img": "https://t.scdn.co/images/c6677aa51acf4121b66b9d1f231bd427.png", 144 | "title": "RADAR" 145 | }, 146 | { 147 | "bg": "rgb(240, 55, 165)", 148 | "img": "https://t.scdn.co/images/16e40e64d2a74fa8a0a020d456e6541d.jpeg", 149 | "title": "Fresh Finds" 150 | }, 151 | { 152 | "bg": "rgb(20, 138, 8)", 153 | "img": "https://i.scdn.co/image/ab67706f0000000284a1ec26f589f0d569805a07", 154 | "title": "EQUAL" 155 | }, 156 | { 157 | "bg": "rgb(175, 40, 150)", 158 | "img": "https://i.scdn.co/image/ab67706f00000002caa115cbdb8cd3d39d67cdc0", 159 | "title": "Party" 160 | }, 161 | { 162 | "bg": "rgb(71, 125, 149)", 163 | "img": "https://i.scdn.co/image/ab67706f00000002ffa215be1a4c64e3cbf59d1e", 164 | "title": "In the car" 165 | }, 166 | { 167 | "bg": "rgb(30, 50, 100)", 168 | "img": "https://i.scdn.co/image/ab67706f00000002b70e0223f544b1faa2e95ed0", 169 | "title": "Sleep" 170 | }, 171 | { 172 | "bg": "rgb(156, 240, 225)", 173 | "img": "https://t.scdn.co/media/links/workout-274x274.jpg", 174 | "title": "Workout" 175 | }, 176 | { 177 | "bg": "rgb(96, 129, 8)", 178 | "img": "https://i.scdn.co/image/ab67706f000000025f7327d3fdc71af27917adba", 179 | "title": "Indie" 180 | }, 181 | { 182 | "bg": "rgb(215, 242, 125)", 183 | "img": "https://t.scdn.co/images/ee9451b3ed474c82b1da8f9b5eafc88f.jpeg", 184 | "title": "Alternative" 185 | }, 186 | { 187 | "bg": "rgb(186, 93, 7)", 188 | "img": "https://t.scdn.co/images/4c8b58ab42b54296ad5379514d36edac", 189 | "title": "Decades" 190 | }, 191 | { 192 | "bg": "rgb(119, 119, 119)", 193 | "img": "https://i.scdn.co/image/ab67706f0000000285704160b49125ac95099ec8", 194 | "title": "Metal" 195 | }, 196 | { 197 | "bg": "rgb(71, 125, 149)", 198 | "img": "https://i.scdn.co/image/ab67706f00000002ec9d60059aa215a7ba364695", 199 | "title": "At Home" 200 | }, 201 | { 202 | "bg": "rgb(141, 103, 171)", 203 | "img": "https://t.scdn.co/images/15a38c44c4484cc3a078aaab5bd4e828", 204 | "title": "Kids & Family" 205 | }, 206 | { 207 | "bg": "rgb(220, 20, 140)", 208 | "img": "https://i.scdn.co/image/ab67706f000000023c5a4aaf5df054a9beeb3d82", 209 | "title": "R&B" 210 | }, 211 | { 212 | "bg": "rgb(141, 103, 171)", 213 | "img": "https://t.scdn.co/media/derived/reggae-274x274_2f11a0500528532b3bc580e3428e9610_0_0_274_274.jpg", 214 | "title": "Reggae" 215 | }, 216 | { 217 | "bg": "rgb(80, 55, 80)", 218 | "img": "https://i.scdn.co/image/ab67706f00000002e4eadd417a05b2546e866934", 219 | "title": "Focus" 220 | }, 221 | { 222 | "bg": "rgb(30, 50, 100)", 223 | "img": "https://t.scdn.co/images/cad629fb65a14de4beddb38510e27cb1", 224 | "title": "Frequency" 225 | }, 226 | { 227 | "bg": "rgb(80, 155, 245)", 228 | "img": "https://t.scdn.co/images/c9a01586687a45a78c56d9be5aed3c79.jpeg", 229 | "title": "Pride" 230 | }, 231 | { 232 | "bg": "rgb(140, 25, 50)", 233 | "img": "https://i.scdn.co/image/ab67706f0000000213601d4833623a4d6b328e38", 234 | "title": "Romance" 235 | }, 236 | { 237 | "bg": "rgb(175, 40, 150)", 238 | "img": "https://i.scdn.co/image/ab67706f000000026abff8de68c75470ea8f0665", 239 | "title": "TV & Movies" 240 | }, 241 | { 242 | "bg": "rgb(175, 40, 150)", 243 | "img": "https://t.scdn.co/images/27922fb7882e4d078c59b29cef4111b9", 244 | "title": "Disney" 245 | }, 246 | { 247 | "bg": "rgb(141, 103, 171)", 248 | "img": "https://i.scdn.co/image/ab67706f000000023e0130fcd5d106f1402b4707", 249 | "title": "Classical" 250 | }, 251 | { 252 | "bg": "rgb(186, 93, 7)", 253 | "img": "https://i.scdn.co/image/ab67706f000000025a051b0271d3e98edfdc4c09", 254 | "title": "Cooking & Dining" 255 | }, 256 | { 257 | "bg": "rgb(225, 51, 0)", 258 | "img": "https://i.scdn.co/image/ab67706f00000002a980b152e708b33c6516d848", 259 | "title": "Country" 260 | }, 261 | { 262 | "bg": "rgb(165, 103, 82)", 263 | "img": "https://i.scdn.co/image/ab67656300005f1ff234909e69a68d92ca0af6ca", 264 | "title": "Wellness" 265 | }, 266 | { 267 | "bg": "rgb(20, 138, 8)", 268 | "img": "https://i.scdn.co/image/ab67706f00000002978b9f4a4f40b430fd0d837e", 269 | "title": "K-Pop" 270 | }, 271 | { 272 | "bg": "rgb(235, 30, 50)", 273 | "img": "https://t.scdn.co/images/1a416fb97f5647858c7f09c9cb6e7301", 274 | "title": "Netflix" 275 | }, 276 | { 277 | "bg": "rgb(30, 50, 100)", 278 | "img": "https://i.scdn.co/image/ab67706f00000002d72ef75e14ca6f60ea2364c2", 279 | "title": "Jazz" 280 | }, 281 | { 282 | "bg": "rgb(220, 20, 140)", 283 | "img": "https://i.scdn.co/image/ab67706f000000026e1034ebd7b7c86546c6acca", 284 | "title": "Soul" 285 | }, 286 | { 287 | "bg": "rgb(30, 50, 100)", 288 | "img": "https://i.scdn.co/image/ab67706f0000000275251d7d488b0fd69e4c50bd", 289 | "title": "Punk" 290 | }, 291 | { 292 | "bg": "rgb(13, 115, 236)", 293 | "img": "https://i.scdn.co/image/ab67706f00000002a76a2ccb454ff0e1720a51a5", 294 | "title": "Caribbean" 295 | }, 296 | { 297 | "bg": "rgb(225, 17, 139)", 298 | "img": "https://t.scdn.co/images/6a48e36b373a4d879a9340076db03a7b", 299 | "title": "Latin" 300 | }, 301 | { 302 | "bg": "rgb(232, 17, 91)", 303 | "img": "https://i.scdn.co/image/ab67706f0000000221a2087747d946f16704b8af", 304 | "title": "Gaming" 305 | }, 306 | { 307 | "bg": "rgb(45, 70, 185)", 308 | "img": "https://t.scdn.co/images/44cf5615d3244f289fcedefa96b85db9", 309 | "title": "Travel" 310 | }, 311 | { 312 | "bg": "rgb(71, 125, 149)", 313 | "img": "https://i.scdn.co/image/ab67706f000000028ed1a5002b96c2ea882541b2", 314 | "title": "Instrumental" 315 | }, 316 | { 317 | "bg": "rgb(71, 125, 149)", 318 | "img": "https://t.scdn.co/images/a45c0978c7784da8b83cadbca8b815d1", 319 | "title": "Ambient" 320 | }, 321 | { 322 | "bg": "rgb(13, 115, 236)", 323 | "img": "https://t.scdn.co/images/b4182906bf244b4994805084c057e9ee.jpeg", 324 | "title": "Tastemakers" 325 | }, 326 | { 327 | "bg": "rgb(80, 155, 245)", 328 | "img": "https://t.scdn.co/images/487ecec9ae594690a55c0150b1958eff", 329 | "title": "Music + Talk" 330 | }, 331 | { 332 | "bg": "rgb(30, 50, 100)", 333 | "img": "https://t.scdn.co/media/derived/trending-274x274_7b238f7217985e79d3664f2734347b98_0_0_274_274.jpg", 334 | "title": "Trending" 335 | }, 336 | { 337 | "bg": "rgb(141, 103, 171)", 338 | "img": "https://i.scdn.co/image/ab67706f00000002d3f07aa10d05fb4baab12b94", 339 | "title": "Arab" 340 | }, 341 | { 342 | "bg": "rgb(180, 155, 200)", 343 | "img": "https://t.scdn.co/images/7c9a6fd84067448f8d2d1c9f7873be5e.jpeg", 344 | "title": "Ramadan" 345 | }, 346 | { 347 | "bg": "rgb(230, 30, 50)", 348 | "img": "https://i.scdn.co/image/ab67706f00000002f16913f0326b9d44bf78fc88", 349 | "title": "Funk" 350 | }, 351 | { 352 | "bg": "rgb(140, 25, 50)", 353 | "img": "https://i.scdn.co/image/ab676d63000076a0c9657833d9c169782b961c9c", 354 | "title": "Songwriters" 355 | }, 356 | { 357 | "bg": "rgb(30, 50, 100)", 358 | "img": "https://i.scdn.co/image/ab67706f0000000237df164786f688dd0ccd8744", 359 | "title": "Folk & Acoustic" 360 | }, 361 | { 362 | "bg": "rgb(160, 195, 210)", 363 | "img": "https://t.scdn.co/images/6fe5cd3ebc8c4db7bb8013152b153505", 364 | "title": "Blues" 365 | }, 366 | { 367 | "bg": "rgb(20, 138, 8)", 368 | "img": "https://i.scdn.co/image/ab67706f000000025f0ff9251e3cfe641160dc31", 369 | "title": "League of Legends" 370 | }, 371 | { 372 | "bg": "rgb(245, 115, 160)", 373 | "img": "https://t.scdn.co/images/29bfbe4d5a564bad8f10f9e6d6e654e0.jpeg", 374 | "title": "Word" 375 | } 376 | ]; -------------------------------------------------------------------------------- /src/data/navbarItems.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { Home, Search, Books, Plus, Heart } from 'tabler-icons-react'; 3 | 4 | type NavbarItem = { 5 | url: string; 6 | icon: ReactNode; 7 | title: string; 8 | supressLink: boolean; 9 | } 10 | 11 | export const navbarItems: NavbarItem[] = [ 12 | { 13 | url: '/', 14 | icon: , 15 | title: 'Home', 16 | supressLink: false 17 | }, 18 | { 19 | url: '/search', 20 | icon: , 21 | title: 'Search', 22 | supressLink: false 23 | }, 24 | { 25 | url: '/collection/playlists', 26 | icon: , 27 | title: 'Library', 28 | supressLink: false 29 | }, 30 | { 31 | url: '/', 32 | icon: (
33 | 34 |
), 35 | title: 'Create Playlist', 36 | supressLink: true 37 | }, 38 | { 39 | url: '/collection/tracks', 40 | icon: (
), 41 | title: 'Liked songs', 42 | supressLink: false 43 | } 44 | ] -------------------------------------------------------------------------------- /src/hooks/useElasticTabs.ts: -------------------------------------------------------------------------------- 1 | import { MouseEvent, useState } from "react" 2 | 3 | const useElasticTabs = () => { 4 | const [style, setStyle] = useState({}); 5 | 6 | const onMouseEnter = (event: MouseEvent) => { 7 | const target = event.target as HTMLButtonElement; 8 | 9 | setStyle({ 10 | width: target.getBoundingClientRect().width, 11 | height: target.getBoundingClientRect().height, 12 | left: target.offsetLeft, 13 | top: target.offsetTop 14 | }); 15 | } 16 | 17 | return { onMouseEnter, style }; 18 | } 19 | 20 | export default useElasticTabs; -------------------------------------------------------------------------------- /src/hooks/useFullscreen.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useFullscreen = () => { 4 | const [isFullscreen, setIsFullscreen] = useState(false); 5 | 6 | useEffect(() => { 7 | document.addEventListener('fullscreenchange', handleFullscreenChange) 8 | 9 | return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); 10 | }, []); 11 | 12 | const handleFullscreenChange = () => { 13 | if (!document.fullscreenElement) { 14 | setIsFullscreen(false); 15 | } 16 | } 17 | 18 | const toggleFullscreen = () => { 19 | if (isFullscreen) { 20 | document.exitFullscreen(); 21 | 22 | setIsFullscreen(false); 23 | } else { 24 | document.documentElement.requestFullscreen(); 25 | 26 | setIsFullscreen(true); 27 | } 28 | } 29 | 30 | return { toggleFullscreen, isFullscreen }; 31 | } 32 | 33 | export default useFullscreen -------------------------------------------------------------------------------- /src/layouts/CollectionLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from '@mantine/hooks'; 2 | import React, { ReactNode } from 'react' 3 | import CollectionMenu from '../components/Header/CollectionMenu/CollectionMenu'; 4 | import { Breakpoint, maxWidth } from '../utils/breakpoints'; 5 | 6 | const CollectionLayout = ({ children }: { children: ReactNode }) => { 7 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 8 | 9 | return ( 10 | <> 11 | {sm && } 12 | {children} 13 | 14 | ) 15 | } 16 | 17 | export default CollectionLayout; -------------------------------------------------------------------------------- /src/layouts/MainLayout.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Drawer, ScrollArea } from '@mantine/core'; 2 | import { useMediaQuery } from '@mantine/hooks'; 3 | import dynamic from 'next/dynamic'; 4 | import React, { FunctionComponent, ReactNode } from 'react' 5 | import { Navbar } from '../components/Navbar'; 6 | import { selectMenuOpened, toggleMenu } from '../store/features/menu.slice'; 7 | import { useAppDispatch, useAppSelector } from '../store/hooks'; 8 | import { Breakpoint, maxWidth } from '../utils/breakpoints'; 9 | const Header = dynamic(() => import('../components/Header/Header'), { 10 | ssr: false 11 | }); 12 | const PlayerBar = dynamic(() => import('../components/PlayerBar/PlayerBar'), { 13 | ssr: false 14 | }); 15 | 16 | const useStyles = createStyles((theme, { sm }: { sm: boolean }) => ({ 17 | wrapper: { 18 | gridTemplateAreas: `"${sm ? 'main-view' : 'nav-bar'} main-view buddy-feed" "now-playing-bar now-playing-bar now-playing-bar"`, 19 | gridTemplateColumns: sm ? '1fr' : 'auto 1fr', 20 | gridTemplateRows: '1fr auto' 21 | }, 22 | mainView: { 23 | gridArea: 'main-view', 24 | overflowY: 'hidden' 25 | }, 26 | drawer: { 27 | backgroundColor: '#000' 28 | } 29 | })); 30 | 31 | const MainLayout: FunctionComponent<{ children: ReactNode }> = ({ children }) => { 32 | const dispatch = useAppDispatch(); 33 | const opened = useAppSelector(selectMenuOpened); 34 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 35 | const { classes, cx } = useStyles({ sm }); 36 | const closeMenu = () => dispatch(toggleMenu()); 37 | 38 | return ( 39 | <> 40 |
41 |
42 | {!sm ? : ( 43 | 53 | 54 | 55 | )} 56 | 57 |
58 |
59 | {children} 60 |
61 |
62 |
63 | 64 |
65 | 66 | ) 67 | } 68 | 69 | export default MainLayout; -------------------------------------------------------------------------------- /src/models/Artist.ts: -------------------------------------------------------------------------------- 1 | export type Artist = { 2 | name: string; 3 | img: string; 4 | } -------------------------------------------------------------------------------- /src/models/Genre.ts: -------------------------------------------------------------------------------- 1 | export type Genre = { 2 | title: string; 3 | img: string; 4 | bg: string; 5 | } -------------------------------------------------------------------------------- /src/models/MediaItem.ts: -------------------------------------------------------------------------------- 1 | export type MediaItem = { 2 | id: string; 3 | title: string; 4 | interpreter: string; 5 | img: string; 6 | } -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | import { MantineProvider } from '@mantine/core'; 4 | import { NowPlayingProvider } from '../contexts/useNowPlaying'; 5 | import { motion, Variants } from 'framer-motion'; 6 | import GlobalFonts from '../theming/GlobalFonts'; 7 | import { appTheme } from '../theming/theme'; 8 | import { Provider } from 'react-redux'; 9 | import { store } from '../store/store'; 10 | import dynamic from 'next/dynamic'; 11 | import { ComponentType, ReactNode } from 'react'; 12 | const MainLayout = dynamic(() => import('../layouts/MainLayout'), { 13 | ssr: false 14 | }); 15 | 16 | type ComponentWithPageLayout = AppProps & { 17 | Component: AppProps['Component'] & { 18 | PageLayout?: ComponentType<{ children: ReactNode }> 19 | } 20 | } 21 | 22 | const appVariants: Variants = { 23 | initial: { 24 | opacity: 0, 25 | y: '5px' 26 | }, 27 | animate: { 28 | opacity: 1, 29 | y: 0, 30 | transition: { 31 | duration: 0.2 32 | } 33 | } 34 | } 35 | 36 | const App = ({ Component, pageProps, router }: ComponentWithPageLayout) => { 37 | return ( 38 | 39 | 48 | 49 | 50 | 51 | 52 | {Component.PageLayout ? ( 53 | 54 | 55 | 56 | ) : ( 57 | 58 | )} 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | export default App; 68 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { createGetInitialProps } from '@mantine/next'; 2 | import Document, { Html, Head, Main, NextScript } from "next/document"; 3 | 4 | const getInitialProps = createGetInitialProps(); 5 | 6 | export default class _Document extends Document { 7 | static getInitialProps = getInitialProps; 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | ) 19 | } 20 | } -------------------------------------------------------------------------------- /src/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/collection/albums/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import MediaItem from '../../../components/MediaItem/MediaItem' 3 | import Section from '../../../components/Section/Section' 4 | import { dummyData } from '../../../data/dummy' 5 | import CollectionLayout from '../../../layouts/CollectionLayout' 6 | 7 | const Albums = () => { 8 | return ( 9 | <> 10 | 11 | Spotify - Library 12 | 13 | 14 |
15 | {dummyData.map((item, key) => ( 16 | 17 | ))} 18 |
19 | 20 | ) 21 | } 22 | 23 | Albums.PageLayout = CollectionLayout; 24 | 25 | export default Albums -------------------------------------------------------------------------------- /src/pages/collection/artists/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import ArtistItem from '../../../components/ArtistItem/ArtistItem' 3 | import Section from '../../../components/Section/Section' 4 | import { artists } from '../../../data/dummy' 5 | import CollectionLayout from '../../../layouts/CollectionLayout' 6 | 7 | const Artists = () => { 8 | return ( 9 | <> 10 | 11 | Spotify - Library 12 | 13 | 14 |
15 | {artists.map((item, key) => ( 16 | 17 | ))} 18 |
19 | 20 | ) 21 | } 22 | 23 | Artists.PageLayout = CollectionLayout; 24 | 25 | export default Artists -------------------------------------------------------------------------------- /src/pages/collection/playlists/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import CollectionLayout from '../../../layouts/CollectionLayout'; 3 | 4 | const Playlists = () => { 5 | return ( 6 | <> 7 | 8 | Spotify - Library 9 | 10 | 11 |
Playlists - WIP
12 | 13 | ) 14 | } 15 | 16 | Playlists.PageLayout = CollectionLayout; 17 | 18 | export default Playlists -------------------------------------------------------------------------------- /src/pages/collection/podcasts/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import CollectionLayout from '../../../layouts/CollectionLayout'; 3 | 4 | const Podcasts = () => { 5 | return ( 6 | <> 7 | 8 | Spotify - Library 9 | 10 | 11 |
Podcasts - WIP
12 | 13 | ) 14 | } 15 | 16 | Podcasts.PageLayout = CollectionLayout; 17 | 18 | export default Podcasts -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import Greeting from '../components/Greeting/Greeting' 3 | import { createStyles } from '@mantine/core' 4 | import SplashItem from '../components/SplashItem/SplashItem' 5 | import { useState } from 'react' 6 | import { artists, dummyData } from '../data/dummy' 7 | import MediaSection from '../components/MediaSection/MediaSection' 8 | import MediaItem from '../components/MediaItem/MediaItem'; 9 | import { useMediaQuery } from '@mantine/hooks' 10 | import { Breakpoint, maxWidth } from '../utils/breakpoints' 11 | import ArtistItem from '../components/ArtistItem/ArtistItem' 12 | import Head from 'next/head' 13 | 14 | const useStyles = createStyles({ 15 | splashItems: { 16 | display: 'grid', 17 | gap: '16px 24px', 18 | gridTemplate: 'auto/repeat(auto-fill, minmax(max(270px, 25%), 1fr))' 19 | }, 20 | dynamicBackgroundWrapper: { 21 | width: 'calc(100% + 4rem)', 22 | backgroundImage: 'linear-gradient(rgba(0,0,0,.6) 0, #121212 100%),url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIHR5cGU9ImZyYWN0YWxOb2lzZSIgYmFzZUZyZXF1ZW5jeT0iLjc1IiBzdGl0Y2hUaWxlcz0ic3RpdGNoIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGZpbHRlcj0idXJsKCNhKSIgb3BhY2l0eT0iLjA1IiBkPSJNMCAwaDMwMHYzMDBIMHoiLz48L3N2Zz4=")', 23 | transition: 'background 1s ease' 24 | } 25 | }) 26 | 27 | const Home: NextPage = () => { 28 | const sm = useMediaQuery(maxWidth(Breakpoint.sm)); 29 | const { classes, cx } = useStyles(); 30 | const [avgColor, setAvgColor] = useState('rgb(83, 83, 83)'); 31 | const reversed = [].concat(dummyData).reverse(); 32 | 33 | const handleColorChange = (color: string) => { 34 | setAvgColor(color); 35 | } 36 | 37 | return ( 38 | <> 39 | 40 | Spotify - Web Player 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
56 |
57 |
58 |
66 | 67 |
68 | {dummyData.slice(0, 6).map(item => ( 69 | 70 | ))} 71 |
72 |
73 | 74 | {dummyData.map(item => ( 75 | 76 | ))} 77 | 78 | 79 | {reversed.map((item, key) => ( 80 | 81 | ))} 82 | 83 | 84 | {artists.map((item, key) => ( 85 | 86 | ))} 87 | 88 |
89 |
90 | 91 | ) 92 | } 93 | 94 | export default Home 95 | -------------------------------------------------------------------------------- /src/pages/search/[keyword]/index.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage } from 'next' 2 | import React from 'react' 3 | 4 | const KeywordSearch: NextPage = () => { 5 | return ( 6 |
KeywordSearch - WIP
7 | ) 8 | } 9 | 10 | export default KeywordSearch -------------------------------------------------------------------------------- /src/pages/search/index.tsx: -------------------------------------------------------------------------------- 1 | import { createStyles, Text } from '@mantine/core'; 2 | import { NextPage } from 'next'; 3 | import Head from 'next/head'; 4 | import React from 'react' 5 | import GenreItem from '../../components/GenreItem/GenreItem'; 6 | import { genres } from '../../data/dummy'; 7 | import { Breakpoint, maxWidth, minWidth } from '../../utils/breakpoints'; 8 | 9 | const useStyles = createStyles({ 10 | gridContainer: { 11 | '--column-width': '197px', 12 | '--column-count': 2, 13 | '--grid-gap': '24px', 14 | display: 'grid', 15 | overflow: 'hidden', 16 | gap: 'var(--grid-gap)', 17 | gridTemplateColumns: 'auto auto', 18 | }, 19 | browseAllContainer: { 20 | '--column-width': '197px', 21 | '--column-count': 5, 22 | '--grid-gap': '24px', 23 | gridAutoRows: 'auto', 24 | gridTemplateRows: '1fr', 25 | overflowY: 'hidden', 26 | gap: 'var(--grid-gap)', 27 | display: 'grid', 28 | gridTemplateColumns: 'repeat(var(--column-count),minmax(0,1fr))', 29 | 30 | [`@media ${maxWidth(Breakpoint.sm)}`]: { 31 | '--column-count': 2, 32 | }, 33 | [`@media ${minWidth(Breakpoint.sm)}`]: { 34 | '--column-count': 3, 35 | }, 36 | [`@media ${minWidth(Breakpoint.md)}`]: { 37 | '--column-count': 3, 38 | }, 39 | [`@media ${minWidth(Breakpoint.lg)}`]: { 40 | '--column-count': 4, 41 | }, 42 | [`@media ${minWidth(Breakpoint.xl)}`]: { 43 | '--column-count': 5, 44 | }, 45 | [`@media ${minWidth(Breakpoint.xxl)}`]: { 46 | '--column-count': 8, 47 | }, 48 | } 49 | }); 50 | 51 | const Search: NextPage = () => { 52 | const { classes } = useStyles(); 53 | 54 | return ( 55 | <> 56 | 57 | Spotify - Search 58 | 59 | 60 |
61 |
62 |
63 |
64 | Your top genres 65 |
66 |
67 |
68 |
69 | 70 | 71 | 72 | 73 |
74 |
75 |
76 |
77 |
78 |
79 | Browse all 80 |
81 |
82 | {genres.map((item, key) => ( 83 | 84 | ))} 85 |
86 |
87 |
88 |
89 | 90 | ) 91 | } 92 | 93 | export default Search; -------------------------------------------------------------------------------- /src/store/features/menu.slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import { RootState } from "../store"; 3 | 4 | type MenuState = { 5 | opened: boolean; 6 | } 7 | 8 | const initialState: MenuState = { 9 | opened: false 10 | } 11 | 12 | const menuSlice = createSlice({ 13 | name: 'menu', 14 | initialState, 15 | reducers: { 16 | toggleMenu: (state: MenuState) => ({ 17 | ...state, 18 | opened: !state.opened 19 | }), 20 | closeMenu: (state: MenuState) => ({ 21 | ...state, 22 | opened: false 23 | }) 24 | } 25 | }); 26 | 27 | export const { toggleMenu, closeMenu } = menuSlice.actions; 28 | 29 | export const selectMenuOpened = (state: RootState) => state.menu.opened; 30 | 31 | export default menuSlice.reducer; -------------------------------------------------------------------------------- /src/store/features/nowPlaying.slice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import type { MediaItem } from "../../models/MediaItem"; 3 | import { dummyData } from "../../data/dummy"; 4 | import { RootState } from "../store"; 5 | 6 | type NowPlayingState = { 7 | media: MediaItem; 8 | playing: boolean; 9 | }; 10 | 11 | const initialState: NowPlayingState = { 12 | media: dummyData[8], 13 | playing: false 14 | }; 15 | 16 | const nowPlayingSlice = createSlice({ 17 | name: 'nowPlaying', 18 | initialState, 19 | reducers: { 20 | setNowPlayingMedia: (state: NowPlayingState, action: PayloadAction) => ({ 21 | ...state, 22 | media: action.payload, 23 | playing: true 24 | }), 25 | setPlaying: (state: NowPlayingState, action: PayloadAction) => ({ 26 | ...state, 27 | playing: action.payload 28 | }) 29 | } 30 | }); 31 | 32 | export const { setNowPlayingMedia, setPlaying } = nowPlayingSlice.actions; 33 | 34 | export const selectNowPlayingMedia = (state: RootState) => state.nowPlaying.media; 35 | 36 | export const selectPlaying = (state: RootState) => ({ playing: state.nowPlaying.playing, id: state.nowPlaying.media.id }); 37 | 38 | export default nowPlayingSlice.reducer; -------------------------------------------------------------------------------- /src/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { AppDispatch, RootState } from "./store"; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import type { TypedUseSelectorHook } from 'react-redux'; 4 | 5 | export const useAppDispatch: () => AppDispatch = useDispatch; 6 | export const useAppSelector: TypedUseSelectorHook = useSelector; -------------------------------------------------------------------------------- /src/store/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import menuSlice from "./features/menu.slice"; 3 | import nowPlayingSlice from "./features/nowPlaying.slice"; 4 | 5 | export const store = configureStore({ 6 | reducer: { 7 | nowPlaying: nowPlayingSlice, 8 | menu: menuSlice 9 | } 10 | }); 11 | 12 | export type RootState = ReturnType; 13 | export type AppDispatch = typeof store.dispatch; -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background-base: #121212; 8 | --background-highlight: #1a1a1a; 9 | --background-press: #000; 10 | --background-elevated-base: #242424; 11 | --background-elevated-highlight: #2a2a2a; 12 | --background-elevated-press: #000; 13 | --background-tinted-base: hsla(0,0%,100%,0.07); 14 | --background-tinted-highlight: hsla(0,0%,100%,0.1); 15 | --background-tinted-press: hsla(0,0%,100%,0.04); 16 | --background-unsafe-for-small-text-base: #121212; 17 | --background-unsafe-for-small-text-highlight: #121212; 18 | --background-unsafe-for-small-text-press: #121212; 19 | --text-base: #fff; 20 | --text-subdued: #a7a7a7; 21 | --text-bright-accent: #1ed760; 22 | --text-negative: #f15e6c; 23 | --text-warning: #ffa42b; 24 | --text-positive: #1ed760; 25 | --text-announcement: #3d91f4; 26 | --essential-base: #fff; 27 | --essential-subdued: #727272; 28 | --essential-bright-accent: #1ed760; 29 | --essential-negative: #e91429; 30 | --essential-warning: #ffa42b; 31 | --essential-positive: #1ed760; 32 | --essential-announcement: #0d72ea; 33 | --decorative-base: #fff; 34 | --decorative-subdued: #292929; 35 | } 36 | 37 | html, body, #__next { 38 | height: 100%; 39 | } 40 | 41 | body { 42 | background-color: #121212 !important; 43 | } 44 | } -------------------------------------------------------------------------------- /src/theming/GlobalFonts.tsx: -------------------------------------------------------------------------------- 1 | import { Global } from "@mantine/core"; 2 | 3 | const GlobalFonts = () => { 4 | return ( 5 | 37 | ) 38 | } 39 | 40 | export default GlobalFonts; -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Fonts Magazine.txt: -------------------------------------------------------------------------------- 1 | Download the complete set of this font family at "https://fontsmagazine.com/" 2 | 3 | "Fonts Magazine" Features as an amazing huge best quality collection of free fonts, 4 | premium fonts, and dingbats. 5 | 6 | Some fonts provided are trial versions of the full versions and may not allow embedding, 7 | Unless a commercial license is purchased or may contain a limited character set. 8 | Please review any files included with your download, 9 | Which will usually include information on the usage and licenses of the fonts. 10 | If no information is provided, 11 | Please use at your own discretion or contact the author directly. 12 | 13 | Note: For using this font commercially mention (Fontsmagazine.com) as a provider for it necessarily. -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-Black.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-Black.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-Bold.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-BookItalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-BookItalic.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-Light.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-Light.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-Thin.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-Thin.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-ThinItalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-ThinItalic.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-UltraItalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-UltraItalic.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-XLight.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-XLight.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/Gotham-XLightItalic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/Gotham-XLightItalic.otf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamBold.ttf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamBoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamBoldItalic.ttf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamBook.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamBook.ttf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamBookItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamBookItalic.ttf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamLight.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamLight.ttf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamLightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamLightItalic.ttf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamMedium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamMedium.ttf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamMediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamMediumItalic.ttf -------------------------------------------------------------------------------- /src/theming/fonts/Gotham-Font/GothamMedium_1.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gamekohl/react-spotify/e3980b31953a06e049e6b23c17206428549f7006/src/theming/fonts/Gotham-Font/GothamMedium_1.ttf -------------------------------------------------------------------------------- /src/theming/theme.ts: -------------------------------------------------------------------------------- 1 | import { MantineThemeOverride } from "@mantine/core"; 2 | 3 | export const appTheme: MantineThemeOverride = { 4 | colors: { 5 | spotifyAccent: [ 6 | "#e0ffeb", 7 | "#b8f6cf", 8 | "#8eefb1", 9 | "#63e792", 10 | "#39e074", 11 | "#1fc65a", 12 | "#149a45", 13 | "#096e30", 14 | "#00431b", 15 | "#001804" 16 | ] 17 | }, 18 | components: { 19 | Button: { 20 | styles: { 21 | root: { 22 | borderRadius: '500px', 23 | transition: 'background-color .3s ease' 24 | } 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/utils/breakpoints.ts: -------------------------------------------------------------------------------- 1 | export enum Breakpoint { 2 | xs = '576px', 3 | sm = '768px', 4 | md = '992px', 5 | lg = '1200px', 6 | xl = '1400px', 7 | xxl = '1401px', 8 | }; 9 | 10 | export const minWidth = (breakpoint: Breakpoint) => { 11 | return `(min-width: ${breakpoint})`; 12 | } 13 | 14 | export const maxWidth = (breakpoint: Breakpoint) => { 15 | return `(max-width: ${breakpoint})`; 16 | } -------------------------------------------------------------------------------- /src/utils/styles.ts: -------------------------------------------------------------------------------- 1 | import { createStyles } from "@mantine/core"; 2 | 3 | export const useStyles = createStyles({}); -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./src/pages/**/*.{js,ts,jsx,tsx}", 4 | "./src/components/**/*.{js,ts,jsx,tsx}", 5 | "./src/layouts/**/*.{js,ts,jsx,tsx}", 6 | "./src/data/**/*.{js,ts,jsx,tsx}" 7 | ], 8 | theme: { 9 | fontFamily: { 10 | 'sans': ['Gotham', 'ui-sans-serif'] 11 | }, 12 | extend: {}, 13 | }, 14 | plugins: [ 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true, 21 | "strictNullChecks": false 22 | }, 23 | "include": [ 24 | "next-env.d.ts", 25 | "**/*.ts", 26 | "**/*.tsx" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } --------------------------------------------------------------------------------