├── src ├── APIs │ ├── testApi.ts │ ├── showApi.ts │ ├── episodeApi.ts │ ├── categoryApi.ts │ ├── playerApi.ts │ ├── browserApi.ts │ ├── artistApi.ts │ ├── userApi.ts │ ├── trackApi.ts │ ├── authorizeApi.ts │ ├── handleRefreshToken.ts │ ├── categoriesApi.ts │ ├── searchApi.ts │ ├── getRefreshToken.ts │ ├── getAccessToken.ts │ └── axiosClient.ts ├── vite-env.d.ts ├── components │ ├── ArtistModal │ │ ├── ExternalLinkItem │ │ │ ├── ExternalLinkItem.module.scss │ │ │ └── ExternalLinkItem.tsx │ │ ├── ArtistModal.module.scss │ │ └── ArtistModal.tsx │ ├── ShowsList │ │ ├── ShowsList.module.scss │ │ └── ShowsList.tsx │ ├── Sidebar │ │ ├── Sidebar.module.scss │ │ ├── Sidebar.tsx │ │ ├── Nav │ │ │ ├── Nav.module.scss │ │ │ └── Nav.tsx │ │ └── Library │ │ │ └── Library.module.scss │ ├── UIs │ │ ├── index.ts │ │ ├── ThumbDefault │ │ │ ├── ThumbDefault.module.scss │ │ │ └── ThumbDefault.tsx │ │ ├── ArtistCityStats │ │ │ ├── ArtistCityStats.module.scss │ │ │ └── ArtistCityStats.tsx │ │ ├── PlayButton │ │ │ ├── PlayButton.module.scss │ │ │ └── PlayButton.tsx │ │ ├── Image │ │ │ ├── Image.module.scss │ │ │ └── Image.tsx │ │ ├── SubTitle │ │ │ └── SubTitle.module.scss │ │ └── Range │ │ │ ├── Range.module.scss │ │ │ └── Range.tsx │ ├── AudioPlayer │ │ ├── AudioPlayer.module.scss │ │ ├── Right │ │ │ └── Right.module.scss │ │ ├── AudioPlayer.tsx │ │ ├── PlayerControl │ │ │ └── PlayerControl.module.scss │ │ └── Left │ │ │ ├── Left.module.scss │ │ │ └── Left.tsx │ ├── SearchBanner │ │ ├── SearchBanner.module.scss │ │ ├── BannerItem │ │ │ ├── BannerItem.tsx │ │ │ └── BannerItem.module.scss │ │ └── SearchBanner.tsx │ ├── Header │ │ └── ArtistList │ │ │ ├── ArtistList.module.scss │ │ │ └── ArtistList.tsx │ ├── AudioPlayerF.tsx │ ├── Navbar │ │ └── UserDropdown │ │ │ ├── UserDropdown.module.scss │ │ │ └── UserDropdown.tsx │ ├── Footer │ │ ├── LinkGroup │ │ │ ├── LinkGroup.module.scss │ │ │ └── LinkGroup.tsx │ │ ├── Footer.module.scss │ │ └── Footer.tsx │ ├── SearchResult │ │ ├── SearchNotFound │ │ │ ├── SearchNotFound.module.scss │ │ │ └── SearchNotFound.tsx │ │ ├── SearchResult.module.scss │ │ └── TopResult │ │ │ └── TopResult.module.scss │ ├── TopTracks │ │ ├── TopTracks.module.scss │ │ └── TopTracks.tsx │ ├── Discography │ │ ├── Discography.module.scss │ │ └── Discography.tsx │ ├── Greeting │ │ ├── Greeting.module.scss │ │ └── Greeting.tsx │ ├── AboutShow │ │ ├── AboutShow.module.scss │ │ └── AboutShow.tsx │ ├── Section │ │ └── Section.module.scss │ ├── SidebarItem │ │ ├── SidebarItem.module.scss │ │ └── SidebarItem.tsx │ ├── SongList │ │ └── SongList.module.scss │ ├── SongItemTag │ │ └── SongItemTag.module.scss │ ├── AboutArtist │ │ ├── AboutArtist.module.scss │ │ └── AboutArtist.tsx │ ├── Alert │ │ ├── Alert.module.scss │ │ └── Alert.tsx │ ├── index.ts │ ├── ShowItem │ │ └── ShowItem.module.scss │ ├── PlayingView │ │ ├── NextSong │ │ │ ├── NextSong.module.scss │ │ │ └── NextSong.tsx │ │ ├── PlayingView.module.scss │ │ └── PlayingView.tsx │ ├── ArtistBanner │ │ ├── ArtistBanner.module.scss │ │ └── ArtistBanner.tsx │ └── SectionItem │ │ └── SectionItem.module.scss ├── scss │ ├── _animations.scss │ ├── _breakpoint.scss │ ├── _global.scss │ ├── _variable.scss │ └── _mixin.scss ├── assets │ ├── fonts │ │ ├── CircularSp-Bold.woff2 │ │ ├── CircularSp-Book.woff2 │ │ ├── spoticon_regular.woff2 │ │ ├── CircularSpTitle-Bold.woff2 │ │ ├── CircularSpTitle-Black.woff2 │ │ └── CircularSpTitle-Tall-Bold.woff2 │ └── image │ │ ├── animation │ │ └── equaliser-animated-green.f5eb96f2.gif │ │ └── logo │ │ └── logo.svg ├── utils │ ├── documentTitle.ts │ ├── fetchLocalData.ts │ ├── transformDomain.ts │ ├── normalizeAlbum.ts │ ├── unicodeDecoder.ts │ ├── dateFormatConvertor.ts │ ├── generateRandomString.ts │ ├── stringCleaner.ts │ ├── normalizeTrack.ts │ ├── deleteAllCookies.ts │ ├── htmlCleaner.ts │ ├── index.ts │ ├── fetchSidebarData.ts │ ├── durationConvertor.ts │ └── fetchHomePageData.ts ├── constants │ └── auth.ts ├── types │ ├── user.ts │ ├── homePage.ts │ ├── search.ts │ ├── playlist.ts │ ├── album.ts │ ├── sidebar.ts │ ├── show.ts │ ├── artist.ts │ ├── others.ts │ ├── section.ts │ ├── track.ts │ └── countries.ts ├── hooks │ ├── useColorGenerator.ts │ ├── useEllipsisVertical.ts │ ├── index.ts │ ├── useEllipsisHorizontal.ts │ ├── useRaisedColorTone.ts │ ├── useComponentSize.ts │ └── useDominantColor.ts ├── pages │ ├── Search │ │ ├── Search.module.scss │ │ └── Search.tsx │ ├── Section │ │ └── Section.module.scss │ ├── test.tsx │ ├── Home │ │ └── Home.module.scss │ ├── Genre │ │ └── Genre.module.scss │ ├── Playlist │ │ └── Playlist.module.scss │ ├── Album │ │ └── Album.module.scss │ ├── Queue │ │ ├── Queue.module.scss │ │ └── Queue.tsx │ ├── Show │ │ └── Show.module.scss │ ├── Artist │ │ └── Artist.module.scss │ └── Episode │ │ └── Episode.module.scss ├── resizable.scss ├── layouts │ ├── TopLayout │ │ ├── TopLayout.module.scss │ │ └── TopLayout.tsx │ ├── LoadingLayout │ │ ├── LoadingLayout.module.scss │ │ └── LoadingLayout.tsx │ └── RootLayout │ │ ├── RootLayout.module.scss │ │ └── RootLayout.tsx ├── index.scss ├── main.tsx ├── config │ └── spotify.ts ├── contexts │ ├── MainLayoutContext.tsx │ ├── SearchContext.tsx │ ├── AuthContext.tsx │ └── HomePageContext.tsx └── App.tsx ├── public ├── _redirects ├── data │ ├── initSongs.json │ ├── 00002.json │ └── 00001.json └── vite.svg ├── types.d.ts ├── saved.txt ├── tsconfig.node.json ├── vite.config.ts ├── .gitignore ├── .eslintrc.cjs ├── .prettierrc ├── index.html ├── SECURITY.md ├── tsconfig.json ├── LICENSE ├── package.json └── .github └── workflows └── codeql.yml /src/APIs/testApi.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@uidotdev/usehooks'; -------------------------------------------------------------------------------- /saved.txt: -------------------------------------------------------------------------------- 1 | https://rapidapi.com/UnlimitedAPI/api/spotify-web2/ -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/components/ArtistModal/ExternalLinkItem/ExternalLinkItem.module.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/scss/_animations.scss: -------------------------------------------------------------------------------- 1 | @mixin text-transition { 2 | transition: all 0.2s cubic-bezier(0.215, 0.61, 0.355, 1); 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/fonts/CircularSp-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan204-dev/spotify-react-typescript/HEAD/src/assets/fonts/CircularSp-Bold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/CircularSp-Book.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan204-dev/spotify-react-typescript/HEAD/src/assets/fonts/CircularSp-Book.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/spoticon_regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan204-dev/spotify-react-typescript/HEAD/src/assets/fonts/spoticon_regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/CircularSpTitle-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan204-dev/spotify-react-typescript/HEAD/src/assets/fonts/CircularSpTitle-Bold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/CircularSpTitle-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan204-dev/spotify-react-typescript/HEAD/src/assets/fonts/CircularSpTitle-Black.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/CircularSpTitle-Tall-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan204-dev/spotify-react-typescript/HEAD/src/assets/fonts/CircularSpTitle-Tall-Bold.woff2 -------------------------------------------------------------------------------- /src/components/ShowsList/ShowsList.module.scss: -------------------------------------------------------------------------------- 1 | .shows-list-wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .title { 7 | font-size: 24px; 8 | 9 | } -------------------------------------------------------------------------------- /src/utils/documentTitle.ts: -------------------------------------------------------------------------------- 1 | const documentTitle = (title: string): void => { 2 | if (!title) return 3 | window.document.title = title 4 | } 5 | 6 | export default documentTitle -------------------------------------------------------------------------------- /src/assets/image/animation/equaliser-animated-green.f5eb96f2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tuan204-dev/spotify-react-typescript/HEAD/src/assets/image/animation/equaliser-animated-green.f5eb96f2.gif -------------------------------------------------------------------------------- /src/utils/fetchLocalData.ts: -------------------------------------------------------------------------------- 1 | const fetchLocalData = async (fileName: string) => { 2 | const response = await fetch(`../assets/data/${fileName}.json`) 3 | const data = await response.json() 4 | 5 | return data 6 | } 7 | 8 | export default fetchLocalData 9 | -------------------------------------------------------------------------------- /src/constants/auth.ts: -------------------------------------------------------------------------------- 1 | import { scopes } from '@/config/spotify' 2 | 3 | export const END_POINT = 'https://accounts.spotify.com/authorize' 4 | export const RESPONSE_TYPE = 'code' 5 | export const SCOPE = scopes 6 | export const REDIRECT_URI = `${window.location.origin}/` 7 | -------------------------------------------------------------------------------- /src/utils/transformDomain.ts: -------------------------------------------------------------------------------- 1 | import { REDIRECT_URI } from '@/constants/auth' 2 | 3 | const transformDomain = (inputString: string): string => { 4 | return inputString.replace(/https:\/\/open\.spotify\.com\//g, REDIRECT_URI) 5 | } 6 | 7 | export default transformDomain 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .sidebar { 4 | // width: max(20%, 280px); 5 | // max-width: 600px; 6 | display: flex; 7 | flex-direction: column; 8 | gap: $panel-gap; 9 | min-width: 280px; 10 | max-width: 500px; 11 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | resolve: { 8 | alias: { 9 | '@': '/src', 10 | }, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /src/utils/normalizeAlbum.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyAlbum } from '@/types/album' 2 | 3 | const normalizeAlbum = (album: any): SpotifyAlbum => { 4 | return { 5 | images: album.coverArt.sources, 6 | id: album.uri.split(':').pop(), 7 | } 8 | } 9 | 10 | export default normalizeAlbum 11 | -------------------------------------------------------------------------------- /src/types/user.ts: -------------------------------------------------------------------------------- 1 | import { countries } from './countries' 2 | import { ImageSource } from './others' 3 | 4 | export interface UserData { 5 | display_name?: string 6 | id?: string 7 | images?: ImageSource[] 8 | email?: string 9 | country?: countries 10 | followers?: { total?: number } 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/unicodeDecoder.ts: -------------------------------------------------------------------------------- 1 | export const unicodeDecoder = (input: string | undefined): string => { 2 | if(!input) return '' 3 | return input.replace( 4 | /&#(\d+);/g, 5 | (_: string, dec: string): string => { 6 | return String.fromCharCode(parseInt(dec, 10)) 7 | } 8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /src/types/homePage.ts: -------------------------------------------------------------------------------- 1 | import { countries } from "./countries" 2 | 3 | export interface NewReleasesArgs { 4 | accessToken: string 5 | limit: number 6 | country: countries 7 | } 8 | 9 | export interface FeaturedPlaylistsProps { 10 | limit: number 11 | accessToken: string 12 | country: countries 13 | } 14 | -------------------------------------------------------------------------------- /src/components/UIs/index.ts: -------------------------------------------------------------------------------- 1 | import SubTitle from './SubTitle/SubTitle' 2 | import PlayButton from './PlayButton/PlayButton' 3 | import Image from './Image/Image' 4 | import Range from './Range/Range' 5 | import ThumbDefault from './ThumbDefault/ThumbDefault' 6 | 7 | export { SubTitle, PlayButton, Image, Range, ThumbDefault } 8 | -------------------------------------------------------------------------------- /src/types/search.ts: -------------------------------------------------------------------------------- 1 | export interface SearchBannerItem { 2 | title?: string 3 | imgUrl?: string 4 | id?: string 5 | bgColor?: string 6 | } 7 | 8 | export interface CategoryItem { 9 | icons: { 10 | height: number 11 | width: number 12 | url: string 13 | }[] 14 | id: string 15 | name: string 16 | } 17 | -------------------------------------------------------------------------------- /src/APIs/showApi.ts: -------------------------------------------------------------------------------- 1 | import { spotifyApiDev } from './axiosClient' 2 | 3 | interface ShowApiProps { 4 | id?: string 5 | } 6 | 7 | const showApi = async (params: ShowApiProps) => { 8 | const { id } = params 9 | 10 | const { data } = await spotifyApiDev.get(`shows/${id}`) 11 | 12 | return data 13 | } 14 | 15 | export default showApi 16 | -------------------------------------------------------------------------------- /src/components/UIs/ThumbDefault/ThumbDefault.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | 3 | .wrapper { 4 | width: 100%; 5 | height: 100%; 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | background-color: $bg-card-highlight; 10 | 11 | .icon { 12 | color: $text-neutral; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useColorGenerator.ts: -------------------------------------------------------------------------------- 1 | //https://rgbcolorpicker.com/random 2 | 3 | const useColorGenerator = () => { 4 | return ( 5 | 'hsl(' + 6 | 360 * Math.random() + 7 | ',' + 8 | (60 + 30 * Math.random()) + 9 | '%,' + 10 | (30 + 20 * Math.random()) + 11 | '%)' 12 | ) 13 | } 14 | 15 | export default useColorGenerator 16 | -------------------------------------------------------------------------------- /src/pages/Search/Search.module.scss: -------------------------------------------------------------------------------- 1 | 2 | .search { 3 | height: 100%; 4 | position: relative; 5 | overflow: hidden; 6 | 7 | } 8 | 9 | .body { 10 | height: 100%; 11 | overflow: hidden; 12 | overflow-y: scroll; 13 | padding-bottom: 32px; 14 | padding-top: 64px; 15 | 16 | &::-webkit-scrollbar { 17 | display: none; 18 | } 19 | } -------------------------------------------------------------------------------- /src/utils/dateFormatConvertor.ts: -------------------------------------------------------------------------------- 1 | const dateFormatConvertor = (dateString: string | undefined): string => { 2 | if(!dateString) return '' 3 | const date = new Date(dateString) 4 | return date.toLocaleDateString('en-US', { 5 | month: 'long', 6 | day: 'numeric', 7 | year: 'numeric', 8 | }) 9 | } 10 | 11 | export default dateFormatConvertor 12 | -------------------------------------------------------------------------------- /src/components/AudioPlayer/AudioPlayer.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .wrapper { 4 | height: 100%; 5 | background-color: $black; 6 | display: flex; 7 | flex-direction: row; 8 | padding: 8px; 9 | } 10 | 11 | .left, 12 | .right { 13 | flex: 3; 14 | width: 30%; 15 | } 16 | 17 | .center { 18 | flex: 4; 19 | width: 40%; 20 | } 21 | -------------------------------------------------------------------------------- /src/APIs/episodeApi.ts: -------------------------------------------------------------------------------- 1 | import { spotifyApiDev } from './axiosClient' 2 | 3 | interface EpisodeApiProps { 4 | id: string 5 | } 6 | 7 | const episodeApi = async (params: Partial) => { 8 | const { id } = params 9 | 10 | const { data } = await spotifyApiDev.get(`episodes/${id}`) 11 | 12 | return data 13 | } 14 | 15 | export default episodeApi 16 | -------------------------------------------------------------------------------- /src/components/SearchBanner/SearchBanner.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | margin-top: 16px; 3 | padding-inline: 24px; 4 | 5 | .heading { 6 | font-size: 24px; 7 | margin-top: 28px; 8 | margin-bottom: 16px; 9 | } 10 | 11 | .body { 12 | display: grid; 13 | gap: 24px; 14 | grid-template-columns: repeat(5, 1fr); 15 | } 16 | } 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/resizable.scss: -------------------------------------------------------------------------------- 1 | @import './scss/variable'; 2 | 3 | .split { 4 | display: flex; 5 | flex-direction: row; 6 | height: 100%; 7 | width: 100%; 8 | } 9 | 10 | .gutter { 11 | background-color: inherit; 12 | cursor: col-resize; 13 | } 14 | 15 | .gutter.gutter-horizontal { 16 | height: 100%; 17 | cursor: col-resize; 18 | width: $panel-gap !important; 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | *.env 15 | .env 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /src/components/UIs/ArtistCityStats/ArtistCityStats.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | 3 | .wrapper { 4 | display: flex; 5 | flex-direction: column; 6 | font-size: 14px; 7 | margin-bottom: 16px; 8 | } 9 | 10 | .city { 11 | font-weight: 700; 12 | color: $white; 13 | margin: 0 0 6px; 14 | } 15 | 16 | .number-of-listeners { 17 | color: $text-neutral; 18 | } 19 | -------------------------------------------------------------------------------- /src/pages/Section/Section.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .wrapper { 4 | height: 100%; 5 | position: relative; 6 | overflow: hidden; 7 | } 8 | 9 | .body { 10 | height: 100%; 11 | overflow: hidden; 12 | overflow-y: scroll; 13 | padding-bottom: 32px; 14 | padding-top: $navbar-height; 15 | 16 | &::-webkit-scrollbar { 17 | display: none; 18 | } 19 | } -------------------------------------------------------------------------------- /src/scss/_breakpoint.scss: -------------------------------------------------------------------------------- 1 | @mixin responsive ($breakpoint) { 2 | @if $breakpoint == sm { 3 | @media (max-width : 600px) { 4 | @content 5 | } 6 | } 7 | 8 | @if $breakpoint == md { 9 | @media (max-width : 900px) { 10 | @content 11 | } 12 | } 13 | 14 | @if $breakpoint == lg { 15 | @media (max-width : 1200px) { 16 | @content 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/utils/generateRandomString.ts: -------------------------------------------------------------------------------- 1 | const generateRandomString = (length: number) => { 2 | let text = '' 3 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 4 | 5 | for (let i = 0; i < length; i++) { 6 | text += possible.charAt(Math.floor(Math.random() * possible.length)) 7 | } 8 | return text 9 | } 10 | 11 | export default generateRandomString 12 | -------------------------------------------------------------------------------- /src/APIs/categoryApi.ts: -------------------------------------------------------------------------------- 1 | import { spotifyApiDev } from './axiosClient' 2 | 3 | interface categoryApiProps { 4 | type: string 5 | id: string 6 | } 7 | 8 | const categoryApi = async (params: Partial) => { 9 | const { type, id } = params 10 | 11 | const { data } = await spotifyApiDev.get(`${type}/${id}`) 12 | 13 | return data 14 | } 15 | 16 | export default categoryApi 17 | -------------------------------------------------------------------------------- /src/utils/stringCleaner.ts: -------------------------------------------------------------------------------- 1 | const stringCleaner = (input?: string): string => { 2 | if(!input) return '' 3 | // Replace HTML entities 4 | const replacedEntities = input 5 | .replace(/&/g, "&") 6 | .replace(///g, "/"); 7 | 8 | // Remove duplicate slashes 9 | const cleanedString = replacedEntities.replace(/\/{2,}/g, "/"); 10 | 11 | return cleanedString; 12 | } 13 | 14 | export default stringCleaner -------------------------------------------------------------------------------- /src/components/Header/ArtistList/ArtistList.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .wrapper { 5 | display: flex; 6 | gap: 3px; 7 | @include text-line-clamp(2); 8 | 9 | .name { 10 | color: $text-neutral; 11 | } 12 | 13 | .playlist { 14 | font-size: 14px; 15 | color: $white; 16 | 17 | &:hover { 18 | text-decoration: underline; 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /src/components/AudioPlayerF.tsx: -------------------------------------------------------------------------------- 1 | import { PlayerContext } from '@/contexts/PlayerContext' 2 | import { useContext } from 'react' 3 | 4 | interface AudioPlayerFParams { 5 | audioUrl: string 6 | } 7 | 8 | const AudioPlayerF = ({ audioUrl }: AudioPlayerFParams) => { 9 | const { audioRef } = useContext(PlayerContext) 10 | 11 | audioRef.current = new Audio(audioUrl) 12 | 13 | return
14 | } 15 | 16 | export default AudioPlayerF 17 | -------------------------------------------------------------------------------- /src/pages/test.tsx: -------------------------------------------------------------------------------- 1 | // import { getYoutubeTrackId, getYoutubeVideoId } from '@/apis/getAudioLink' 2 | // import { useEffect } from 'react' 3 | 4 | const Test = () => { 5 | // useEffect(() => { 6 | // const fetchData = async () => { 7 | // await getYoutubeTrackId('Suit & Tie - RPT MCK Hoang Ton album: 99%') 8 | // } 9 | 10 | // fetchData() 11 | // }, []) 12 | 13 | return
test
14 | } 15 | 16 | export default Test 17 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { browser: true, es2020: true }, 3 | extends: [ 4 | 'eslint:recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:react-hooks/recommended', 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, 10 | plugins: ['react-refresh'], 11 | rules: { 12 | 'react-refresh/only-export-components': 'warn', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useEllipsisVertical.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useEllipsisVertical = (e: HTMLElement) => { 4 | const [isActive, setActive] = useState(false) 5 | useEffect(() => { 6 | if (e?.offsetHeight < e?.scrollHeight) { 7 | setActive(true) 8 | } 9 | }, [e?.offsetHeight, e?.scrollHeight]) 10 | 11 | if (isActive) return true 12 | return false 13 | } 14 | 15 | export default useEllipsisVertical 16 | -------------------------------------------------------------------------------- /src/layouts/TopLayout/TopLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .main { 4 | flex: 1; 5 | height: 100%; 6 | background-color: $bg-base; 7 | border-radius: $panel-gap; 8 | -webkit-border-radius: $panel-gap; 9 | overflow: hidden; 10 | -webkit-overflow: hidden; 11 | overflow-y: auto; 12 | position: relative; 13 | } 14 | 15 | .split { 16 | display: flex; 17 | flex-direction: row; 18 | height: 100%; 19 | width: 100%; 20 | } -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import 'normalize.css'; 2 | @import './scss/_global.scss'; 3 | 4 | @font-face { 5 | font-family: CircularSp; 6 | src: url('./assets/fonts/CircularSp-Book.woff2'); 7 | font-weight: 400; 8 | } 9 | 10 | @font-face { 11 | font-family: CircularSp; 12 | src: url('./assets/fonts/CircularSp-Bold.woff2'); 13 | font-weight: 700; 14 | } 15 | 16 | @font-face { 17 | font-family: 'Spoticon'; 18 | src: url('./assets/fonts/spoticon_regular.woff2'); 19 | } -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode, memo } from 'react' 2 | import Library from './Library/Library' 3 | import Nav from './Nav/Nav' 4 | import styles from './Sidebar.module.scss' 5 | 6 | interface SidebarProps { 7 | children?: ReactNode 8 | } 9 | 10 | const Sidebar: FC = () => { 11 | return ( 12 | 16 | ) 17 | } 18 | 19 | export default memo(Sidebar) 20 | -------------------------------------------------------------------------------- /src/scss/_global.scss: -------------------------------------------------------------------------------- 1 | @import './variable'; 2 | 3 | *, 4 | *::after, 5 | *::before { 6 | box-sizing: border-box; 7 | -webkit-box-sizing: border-box; 8 | 9 | scrollbar-width: none; 10 | } 11 | 12 | html { 13 | font-size: 62.5%; 14 | font-family: CircularSp, sans-serif; 15 | } 16 | 17 | body { 18 | color: $white; 19 | } 20 | 21 | a { 22 | text-decoration: none; 23 | } 24 | 25 | p, 26 | h1, 27 | h2, 28 | h3, 29 | h4, 30 | h5, 31 | h6, 32 | span { 33 | line-height: 1.3; 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/Home/Home.module.scss: -------------------------------------------------------------------------------- 1 | .home { 2 | height: 100%; 3 | position: relative; 4 | overflow: hidden; 5 | -webkit-overflow: hidden; 6 | } 7 | 8 | .body { 9 | height: 100%; 10 | overflow: hidden; 11 | overflow-y: scroll; 12 | padding-bottom: 32px; 13 | padding-top: 64px; 14 | position: relative; 15 | // background-color: transparent; 16 | 17 | &::-webkit-scrollbar { 18 | display: none; 19 | } 20 | } 21 | 22 | .pivot-tracking { 23 | position: absolute; 24 | z-index: -999; 25 | } -------------------------------------------------------------------------------- /src/components/UIs/ThumbDefault/ThumbDefault.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styles from './ThumbDefault.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { MusicNote } from '@/assets/icons' 5 | 6 | const cx = classNames.bind(styles) 7 | 8 | const ThumbDefault: FC = () => { 9 | return ( 10 |
11 |
12 | 13 |
14 |
15 | ) 16 | } 17 | 18 | export default ThumbDefault 19 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import useComponentSize from './useComponentSize' 2 | import useDominantColor from './useDominantColor' 3 | import useRaiseColorTone from './useRaisedColorTone' 4 | import useEllipsisVertical from './useEllipsisVertical' 5 | import useEllipsisHorizontal from './useEllipsisHorizontal' 6 | import useColorGenerator from './useColorGenerator' 7 | 8 | export { 9 | useComponentSize, 10 | useDominantColor, 11 | useRaiseColorTone, 12 | useEllipsisVertical, 13 | useEllipsisHorizontal, 14 | useColorGenerator, 15 | } 16 | -------------------------------------------------------------------------------- /src/layouts/LoadingLayout/LoadingLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | 4 | .wrapper { 5 | height: 100vh; 6 | display: flex; 7 | flex-direction: column; 8 | background-color: $black; 9 | } 10 | 11 | .spinner { 12 | width: calc(100% - 16px); 13 | flex: 1; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | background-color: $bg-base; 18 | margin: 8px 8px 0; 19 | border-radius: $panel-border-radius; 20 | } 21 | 22 | .audio-player-bar { 23 | height: 88px; 24 | padding: 8px; 25 | } -------------------------------------------------------------------------------- /src/hooks/useEllipsisHorizontal.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useEllipsisHorizontal = (e: HTMLElement, dependency?: any) => { 4 | const [isActive, setActive] = useState(false) 5 | 6 | useEffect(() => { 7 | setActive(false) 8 | }, [dependency]) 9 | 10 | useEffect(() => { 11 | if (e?.offsetWidth < e?.scrollWidth) { 12 | setActive(true) 13 | } 14 | }, [e?.offsetWidth, e?.scrollWidth]) 15 | 16 | if (isActive) return true 17 | return false 18 | } 19 | 20 | export default useEllipsisHorizontal 21 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "htmlWhitespaceSensitivity": "css", 7 | "insertPragma": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 90, 10 | "proseWrap": "preserve", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleAttributePerLine": false, 15 | "singleQuote": true, 16 | "tabWidth": 2, 17 | "trailingComma": "es5", 18 | "useTabs": false, 19 | "vueIndentScriptAndStyle": false 20 | } -------------------------------------------------------------------------------- /src/APIs/playerApi.ts: -------------------------------------------------------------------------------- 1 | import { spotifyApiClient } from './axiosClient' 2 | 3 | export const getUserQueue = async () => { 4 | const { data } = await spotifyApiClient.get('me/player/queue') 5 | return data 6 | } 7 | 8 | export const handlePause = async () => { 9 | await spotifyApiClient.put(`me/player/pause`) 10 | } 11 | 12 | export const handlePlay = async () => { 13 | await spotifyApiClient.put(`me/player/play`) 14 | } 15 | 16 | export const getPlaybackState = async () => { 17 | const { data } = await spotifyApiClient.get('me/player') 18 | return data 19 | } 20 | -------------------------------------------------------------------------------- /src/components/UIs/PlayButton/PlayButton.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | 3 | .wrapper { 4 | } 5 | 6 | .play-btn { 7 | position: relative; 8 | font-size: 24px; 9 | box-shadow: 0 8px 8px rgba(0, 0, 0, 0.3); 10 | color: $black; 11 | border: none; 12 | cursor: pointer; 13 | transition: all ease-in-out 0.2s; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | background-color: $primary; 18 | transform-origin: center; 19 | outline: none; 20 | opacity: 0.95; 21 | 22 | &-child { 23 | font-size: 28px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/UIs/Image/Image.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | 3 | .wrapper { 4 | width: 100%; 5 | height: 100%; 6 | position: relative; 7 | 8 | img { 9 | width: 100%; 10 | height: 100%; 11 | object-fit: cover; 12 | animation: brighten-up 0.2s; 13 | } 14 | } 15 | 16 | .overlay { 17 | position: absolute; 18 | top: 0; 19 | right: 0; 20 | bottom: 0; 21 | left: 0; 22 | opacity: 0.25; 23 | } 24 | 25 | @keyframes brighten-up { 26 | 0% { 27 | filter: brightness(0.3); 28 | } 29 | 30 | 100% { 31 | filter: brightness(1); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | // import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { BrowserRouter } from 'react-router-dom' 4 | import App from './App.tsx' 5 | import './index.scss' 6 | import { ErrorBoundary } from 'react-error-boundary' 7 | import { Alert } from './components/index.ts' 8 | 9 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 10 | // 11 | }> 12 | 13 | 14 | 15 | 16 | // {/* */} 17 | ) 18 | -------------------------------------------------------------------------------- /src/components/Navbar/UserDropdown/UserDropdown.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .user-dropdown-wrapper { 5 | display: flex; 6 | flex-direction: column; 7 | background-color: $bg-card-highlight; 8 | box-shadow: 0 16px 24px rgba(0, 0, 0, 0.3), 0 6px 8px rgba(0, 0, 0, 0.2); 9 | border-radius: 4px; 10 | overflow: hidden; 11 | } 12 | 13 | .logout-btn { 14 | @include reset-button; 15 | padding: 10px 20px; 16 | font-size: 14px; 17 | font-weight: 700; 18 | background-color: transparent; 19 | color: $white; 20 | cursor: pointer; 21 | } 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Spotify - Clone 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/types/playlist.ts: -------------------------------------------------------------------------------- 1 | import { ImageSource } from './others' 2 | import { SpotifyTrack } from './track' 3 | 4 | export interface PlayListItem { 5 | title?: string 6 | imageUrl?: string 7 | author?: string 8 | id?: string 9 | name?: string 10 | description?: string 11 | images?: ImageSource[] 12 | } 13 | 14 | export interface PlaylistData { 15 | description?: string 16 | id?: string 17 | name?: string 18 | images?: ImageSource[] 19 | owner?: { 20 | display_name?: string 21 | id?: string 22 | } 23 | tracks?: { 24 | items?: { track: SpotifyTrack }[] 25 | total?: number 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/Navbar/UserDropdown/UserDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext } from 'react' 2 | import styles from './UserDropdown.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { AuthContext } from '@/contexts/AuthContext' 5 | 6 | const cx = classNames.bind(styles) 7 | 8 | const UserDropdown: FC = () => { 9 | const { handleLogout } = useContext(AuthContext) 10 | 11 | return ( 12 |
13 | 16 |
17 | ) 18 | } 19 | 20 | export default UserDropdown 21 | -------------------------------------------------------------------------------- /src/utils/normalizeTrack.ts: -------------------------------------------------------------------------------- 1 | import { RapidArtistTrack, SpotifyTrack } from '@/types/track' 2 | import { normalizeAlbum } from '.' 3 | 4 | const normalizeTrack = (rapidTrack: RapidArtistTrack): SpotifyTrack => { 5 | return { 6 | album: normalizeAlbum(rapidTrack?.track?.album), 7 | artists: rapidTrack?.track?.artists.items.map((item: any) => { 8 | return { name: item?.profile?.name, id: item?.uri.split(':').pop() } 9 | }), 10 | duration_ms: rapidTrack?.track?.duration?.totalMilliseconds, 11 | name: rapidTrack?.track?.name, 12 | id: rapidTrack?.track?.id, 13 | } 14 | } 15 | 16 | export default normalizeTrack 17 | -------------------------------------------------------------------------------- /src/components/UIs/ArtistCityStats/ArtistCityStats.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styles from './ArtistCityStats.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { ArtistTopCity } from '@/types/artist' 5 | 6 | const cx = classNames.bind(styles) 7 | 8 | const ArtistCityStats: FC = ({ city, country, numberOfListeners }) => { 9 | return ( 10 |
11 |

{`${city}, ${country}`}

12 | {`${numberOfListeners?.toLocaleString()} listeners`} 13 |
14 | ) 15 | } 16 | 17 | export default ArtistCityStats 18 | -------------------------------------------------------------------------------- /src/layouts/LoadingLayout/LoadingLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styles from './LoadingLayout.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { StageSpinner } from 'react-spinners-kit' 5 | import { AudioPlayer } from '@/components' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | const LoadingLayout: FC = () => { 10 | return ( 11 |
12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | ) 20 | } 21 | 22 | export default LoadingLayout 23 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /src/components/Footer/LinkGroup/LinkGroup.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | 3 | .wrapper { 4 | display: flex; 5 | flex-direction: column; 6 | margin: 0 24px 32px 0; 7 | min-width: 170px; 8 | 9 | .title { 10 | font-size: 16px; 11 | font-weight: 700; 12 | color: $white; 13 | } 14 | 15 | .list { 16 | display: flex; 17 | flex-direction: column; 18 | color: $text-subdued; 19 | 20 | a { 21 | color: inherit; 22 | text-decoration: none; 23 | font-size: 16px; 24 | margin-top: 8px; 25 | margin-bottom: 8px; 26 | 27 | &:hover { 28 | color: $white; 29 | text-decoration: underline; 30 | } 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/components/SearchResult/SearchNotFound/SearchNotFound.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/mixin'; 2 | 3 | .wrapper { 4 | display: flex; 5 | flex-direction: column; 6 | min-height: 100vh; 7 | justify-content: space-between; 8 | } 9 | 10 | 11 | .not-found { 12 | min-height: 400px; 13 | display: flex; 14 | flex-direction: column; 15 | justify-content: center; 16 | align-items: center; 17 | padding-inline: 24px; 18 | overflow: hidden; 19 | 20 | .title { 21 | display: block; 22 | width: 100%; 23 | font-size: 24px; 24 | font-weight: 700; 25 | text-align: center; 26 | @include text-line-clamp(4); 27 | 28 | } 29 | 30 | .msg { 31 | font-size: 14px; 32 | } 33 | } -------------------------------------------------------------------------------- /src/components/TopTracks/TopTracks.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .wrapper { 4 | position: relative; 5 | z-index: 1; 6 | padding-inline: $col-gap; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | .title { 12 | margin: 2px 0 10px; 13 | 14 | h2 { 15 | margin: 0; 16 | font-size: 24px; 17 | } 18 | } 19 | 20 | .show-more-btn { 21 | color: hsla(0,0%,100%,.7); 22 | cursor: pointer; 23 | margin: 4px 0; 24 | 25 | button { 26 | background: none; 27 | border: none; 28 | font-size: 14px; 29 | color: hsla(0,0%,100%,.7); 30 | padding: 16px; 31 | cursor: pointer; 32 | 33 | &:hover { 34 | color: $white; 35 | } 36 | } 37 | 38 | } -------------------------------------------------------------------------------- /src/components/AudioPlayer/Right/Right.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .wrapper { 5 | height: 100%; 6 | display: flex; 7 | flex-direction: row; 8 | justify-content: flex-end; 9 | align-items: center; 10 | padding-right: 16px; 11 | 12 | .btn { 13 | background-color: transparent; 14 | color: $text-subdued; 15 | @include reset-button; 16 | padding: 8px; 17 | cursor: pointer; 18 | 19 | &:hover { 20 | color: $white; 21 | } 22 | } 23 | } 24 | 25 | .volume { 26 | display: flex; 27 | flex-direction: row; 28 | align-items: center; 29 | width: 125px; 30 | } 31 | 32 | .active { 33 | @include button-active; 34 | } 35 | -------------------------------------------------------------------------------- /src/APIs/browserApi.ts: -------------------------------------------------------------------------------- 1 | import { countries } from '@/types/countries' 2 | import { spotifyApiDev } from './axiosClient' 3 | 4 | interface browserApiProps { 5 | limit: number 6 | country: countries 7 | type: 'featured-playlists' | 'new-releases' 8 | } 9 | 10 | const browserApi = async (params: Partial) => { 11 | const { country, limit, type } = params 12 | 13 | const { data } = await spotifyApiDev.get(`browse/${type}`, { 14 | params: { 15 | country: country, 16 | limit: limit, 17 | }, 18 | }) 19 | 20 | if (data?.albums) { 21 | return data.albums.items 22 | } 23 | 24 | if (data?.playlists) { 25 | return data.playlists.items 26 | } 27 | } 28 | 29 | export default browserApi 30 | -------------------------------------------------------------------------------- /src/APIs/artistApi.ts: -------------------------------------------------------------------------------- 1 | import { rapidApiClient, spotifyApiDev } from './axiosClient' 2 | 3 | const artistApi = async (id?: string) => { 4 | if (!id) return 5 | const { data } = await rapidApiClient.get('artist_overview/', { 6 | params: { 7 | id: id, 8 | }, 9 | }) 10 | 11 | return data.data.artist 12 | } 13 | 14 | export const getArtistTopTrack = async (id?: string) => { 15 | if (!id) return 16 | const { data } = await spotifyApiDev.get(`artists/${id}/top-tracks?market=VN`) 17 | return data 18 | } 19 | 20 | export const getArtistAlbums = async (id?: string) => { 21 | if (!id) return 22 | const { data } = await spotifyApiDev.get(`artists/${id}/albums`) 23 | return data 24 | } 25 | 26 | export default artistApi 27 | -------------------------------------------------------------------------------- /src/components/Sidebar/Nav/Nav.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/animations'; 3 | 4 | 5 | .nav { 6 | background-color: $bg-base; 7 | font-size: 1.6rem; 8 | border-radius: $panel-border-radius; 9 | padding: 8px 22px; 10 | 11 | &-item { 12 | display: flex; 13 | justify-content: flex-start; 14 | align-items: center; 15 | gap: 20px; 16 | color: $text-neutral; 17 | @include text-transition; 18 | 19 | &:hover { 20 | color: $white; 21 | } 22 | 23 | 24 | } 25 | 26 | &-icon { 27 | font-size: 24px; 28 | } 29 | 30 | &-name { 31 | font-size: 1.6rem; 32 | font-weight: 700; 33 | text-decoration: none; 34 | } 35 | } 36 | 37 | .active { 38 | color: $white; 39 | } -------------------------------------------------------------------------------- /src/components/UIs/SubTitle/SubTitle.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .artists { 5 | font-size: 14px; 6 | color: $text-neutral; 7 | display: flex; 8 | gap: 3px; 9 | overflow: hidden; 10 | width: 100%; 11 | @include text-in-one-line; 12 | flex: 1; 13 | 14 | .artist-item { 15 | font-size: 14px; 16 | color: $text-neutral; 17 | 18 | 19 | &:hover { 20 | text-decoration: underline; 21 | color: $white; 22 | } 23 | } 24 | } 25 | 26 | .artist-item { 27 | font-size: 14px; 28 | color: $text-neutral; 29 | 30 | 31 | &:hover { 32 | text-decoration: underline; 33 | color: $white; 34 | } 35 | } 36 | 37 | .white-color { 38 | color: $white !important; 39 | } -------------------------------------------------------------------------------- /src/config/spotify.ts: -------------------------------------------------------------------------------- 1 | const scopes = [ 2 | 'ugc-image-upload', 3 | 'user-read-playback-state', 4 | 'user-modify-playback-state', 5 | 'user-read-currently-playing', 6 | 'app-remote-control', 7 | 'streaming', 8 | 'playlist-read-private', 9 | 'playlist-read-collaborative', 10 | 'playlist-modify-private', 11 | 'playlist-modify-public', 12 | 'user-follow-modify', 13 | 'user-follow-read', 14 | 'user-read-playback-position', 15 | 'user-top-read', 16 | 'user-read-recently-played', 17 | 'user-library-modify', 18 | 'user-library-read', 19 | 'user-read-email', 20 | 'user-read-private', 21 | // --- 22 | // 'user-self-provisioning', 23 | // 'openid', 24 | // 'profile', 25 | // 'email', 26 | ].join(' ') 27 | 28 | export { scopes } 29 | -------------------------------------------------------------------------------- /src/utils/deleteAllCookies.ts: -------------------------------------------------------------------------------- 1 | //fix 403 Forbidden 2 | 3 | const deleteAllCookies = () => { 4 | document.cookie = document.cookie.split(';').reduce((newCookie1, keyVal) => { 5 | const pair = keyVal.trim().split('=') 6 | if (pair[0]) { 7 | if (pair[0] !== 'path' && pair[0] !== 'expires') { 8 | newCookie1 += pair[0] + '=;' 9 | } 10 | } 11 | return newCookie1 12 | }, 'expires=Thu, 01 Jan 1970 00:00:00 UTC; path:/;') 13 | 14 | caches.keys().then((keys) => { 15 | keys.forEach((key) => caches.delete(key)) 16 | }) 17 | 18 | // indexedDB.databases().then((dbs) => { 19 | // dbs.forEach((db) => indexedDB.deleteDatabase(db.name as string)) 20 | // }) 21 | 22 | sessionStorage.clear() 23 | } 24 | 25 | export default deleteAllCookies 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "baseUrl": "./src", 23 | "paths": { 24 | "@/*": ["*"] 25 | } 26 | }, 27 | "include": ["src"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /src/APIs/userApi.ts: -------------------------------------------------------------------------------- 1 | import { spotifyApiClient, spotifyApiDev } from './axiosClient' 2 | 3 | export const getUserData = async () => { 4 | const { data } = await spotifyApiClient.get(`me`) 5 | // console.log(data) 6 | return data 7 | } 8 | 9 | export const getUserPlaylist = async (id: string) => { 10 | const { data } = await spotifyApiDev.get(`users/${id}/playlists`, { 11 | params: { 12 | limit: 50, 13 | }, 14 | }) 15 | return data 16 | } 17 | 18 | export const getUserAlbum = async () => { 19 | const { data } = await spotifyApiClient.get('me/albums') 20 | return data 21 | } 22 | 23 | export const getUserTopArtists = async () => { 24 | const { data } = await spotifyApiClient.get('me/top/artists', { 25 | params: { 26 | limit: 50, 27 | }, 28 | }) 29 | return data 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/htmlCleaner.ts: -------------------------------------------------------------------------------- 1 | import { ArtistData } from '@/types/artist' 2 | 3 | export default function htmlCleaner( 4 | htmlString: string | undefined 5 | ): ArtistData[] | null | string { 6 | if (!htmlString) return null 7 | const parser = new DOMParser() 8 | const doc = parser.parseFromString(htmlString, 'text/html') 9 | const playlists: ArtistData[] = [] 10 | 11 | const links = doc.querySelectorAll('a') 12 | if (links.length === 0) { 13 | return htmlString 14 | } 15 | 16 | links.forEach((link) => { 17 | const name = link.textContent?.trim() 18 | const href = link.getAttribute('href') 19 | if (name && href && href.startsWith('spotify:playlist:')) { 20 | const id = href.split(':')[2] 21 | playlists.push({ name, id }) 22 | } 23 | }) 24 | 25 | return playlists 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Discography/Discography.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .wrapper { 5 | padding-inline: $col-gap; 6 | margin-top: 32px; 7 | } 8 | 9 | .title { 10 | 11 | 12 | h2 { 13 | margin: 0; 14 | font-size: 24px; 15 | } 16 | } 17 | 18 | .selection { 19 | display: flex; 20 | gap: 10px; 21 | padding: 7px 0 10px; 22 | margin: 14px 0; 23 | background-color: $bg-base; 24 | min-height: 52px; 25 | 26 | 27 | .btn { 28 | @include button-text(4px 12px, $bg-button, $bg-button-highlight); 29 | color: $white; 30 | font-size: 14px; 31 | border-radius: 32px; 32 | } 33 | 34 | .active { 35 | color: $black; 36 | @include button-text(4px 12px, $white, $white); 37 | } 38 | } 39 | 40 | .section { 41 | margin-inline: -24px; 42 | } -------------------------------------------------------------------------------- /src/hooks/useRaisedColorTone.ts: -------------------------------------------------------------------------------- 1 | function useRaiseColorTone(color: string): string { 2 | // Extract the RGB components from the color code 3 | const red = parseInt(color.substring(1, 3), 16) 4 | const green = parseInt(color.substring(3, 5), 16) 5 | const blue = parseInt(color.substring(5, 7), 16) 6 | 7 | // Calculate the raised color values 8 | const raisedRed = Math.min(Math.round(red * 1.), 255) 9 | const raisedGreen = Math.min(Math.round(green * 1.), 255) 10 | const raisedBlue = Math.min(Math.round(blue * 1.), 255) 11 | 12 | // Convert the raised color values back to hexadecimal 13 | const raisedColor = `#${raisedRed.toString(16).padStart(2, '0')}${raisedGreen 14 | .toString(16) 15 | .padStart(2, '0')}${raisedBlue.toString(16).padStart(2, '0')}` 16 | 17 | return raisedColor 18 | } 19 | 20 | export default useRaiseColorTone 21 | -------------------------------------------------------------------------------- /src/layouts/RootLayout/RootLayout.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .root-layout { 4 | background-color: $black; 5 | height: 100vh; 6 | width: max(100vw, 900px); 7 | overflow: hidden; 8 | display: flex; 9 | gap: $panel-gap; 10 | padding: .8rem; 11 | max-width: 3000px; 12 | display: flex; 13 | flex-direction: column; 14 | overflow: hidden; 15 | } 16 | 17 | .top { 18 | height: calc(100vh - 96px); 19 | } 20 | 21 | .bottom { 22 | height: 72px; 23 | } 24 | 25 | .main { 26 | flex: 1; 27 | height: 100%; 28 | background-color: $bg-base; 29 | border-radius: $panel-gap; 30 | -webkit-border-radius: $panel-gap; 31 | overflow: hidden; 32 | -webkit-overflow: hidden; 33 | overflow-y: auto; 34 | position: relative; 35 | } 36 | 37 | .split { 38 | display: flex; 39 | flex-direction: row; 40 | height: 100%; 41 | width: 100%; 42 | } -------------------------------------------------------------------------------- /src/types/album.ts: -------------------------------------------------------------------------------- 1 | import { ArtistData } from './artist' 2 | import { ImageSource } from './others' 3 | import { SpotifyTrack } from './track' 4 | 5 | export interface AlbumItem { 6 | title?: string 7 | imageUrl?: string 8 | id?: string 9 | author?: string 10 | dateTime?: string 11 | album?: { 12 | id?: string 13 | images?: ImageSource[] 14 | name?: string 15 | artists?: ArtistData[] 16 | } 17 | } 18 | 19 | export interface SpotifyAlbum { 20 | artists?: ArtistData[] 21 | images?: ImageSource[] 22 | id?: string 23 | name?: string 24 | album_type?: 'album' | 'Playlist' | 'single' | 'compilation' | 'podcast' | 'episode' 25 | 26 | release_date?: string 27 | description?: string 28 | tracks?: { 29 | total?: number 30 | items: SpotifyTrack[] 31 | } 32 | copyrights?: { 33 | text?: string 34 | type?: string 35 | }[] 36 | } 37 | -------------------------------------------------------------------------------- /src/components/AudioPlayer/AudioPlayer.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind' 2 | import { FC, useContext } from 'react' 3 | import styles from './AudioPlayer.module.scss' 4 | import Left from './Left/Left' 5 | import PlayerControl from './PlayerControl/PlayerControl' 6 | import { PlayerContext } from '@/contexts/PlayerContext' 7 | import Right from './Right/Right' 8 | 9 | const cx = classNames.bind(styles) 10 | 11 | const AudioPlayer: FC = () => { 12 | const { currentTrack } = useContext(PlayerContext) 13 | 14 | return ( 15 |
16 |
{currentTrack && }
17 | 18 |
19 | 20 |
21 | 22 |
23 | 24 |
25 |
26 | ) 27 | } 28 | 29 | export default AudioPlayer 30 | -------------------------------------------------------------------------------- /src/components/SearchResult/SearchNotFound/SearchNotFound.tsx: -------------------------------------------------------------------------------- 1 | import {FC} from 'react' 2 | import styles from './SearchNotFound.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { Footer } from '@/components' 5 | 6 | const cx = classNames.bind(styles) 7 | 8 | interface SearchNotFoundProps { 9 | query?: string 10 | } 11 | 12 | const SearchNotFound: FC = ({query}) => { 13 | return ( 14 |
17 |
18 |

{`No results found for "${query}"`}

19 | 20 | Please make sure your words are spelled correctly, or use fewer or different 21 | keywords. 22 | 23 |
24 |
25 |
26 | ) 27 | } 28 | 29 | export default SearchNotFound 30 | -------------------------------------------------------------------------------- /src/components/Greeting/Greeting.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/breakpoint'; 3 | 4 | .body { 5 | margin-top: -$navbar-height; 6 | padding-top: $navbar-height; 7 | padding-inline: 24px; 8 | position: relative; 9 | font-family: CircularSp; 10 | font-weight: 700; 11 | transition: all 1s ease; 12 | background-image: linear-gradient(rgba(0, 0, 0, .6) 0, $bg-base 100%), $bg-noise !important; 13 | 14 | .greet { 15 | font-size: 3.2rem; 16 | font-weight: 700; 17 | min-height: 52px; 18 | 19 | p { 20 | margin: 0; 21 | padding: 8px 0; 22 | } 23 | } 24 | 25 | .songs-section { 26 | display: grid; 27 | grid-gap: $row-gap $col-gap; 28 | grid-template: auto / repeat(3, 1fr); 29 | padding: 15px 0; 30 | font-weight: 700; 31 | } 32 | 33 | .songs-section-responsive { 34 | grid-template: auto / repeat(2, 1fr); 35 | } 36 | } -------------------------------------------------------------------------------- /src/components/AboutShow/AboutShow.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .about-show-wrapper { 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .title { 10 | font-size: 24px; 11 | padding: 16px 0; 12 | margin: 0; 13 | } 14 | 15 | .content { 16 | overflow: hidden; 17 | font-size: 16px; 18 | color: $text-neutral; 19 | line-height: 1.5; 20 | margin-bottom: 24px; 21 | position: relative; 22 | 23 | 24 | 25 | a { 26 | color: $text-neutral; 27 | font-weight: 700; 28 | 29 | &:hover { 30 | text-decoration: underline; 31 | } 32 | } 33 | } 34 | 35 | .expand-btn { 36 | button { 37 | @include reset-button; 38 | background-color: transparent; 39 | font-size: 16px; 40 | color: $white; 41 | font-weight: 700; 42 | cursor: pointer; 43 | } 44 | } 45 | 46 | .expanded { 47 | @include text-line-clamp(5); 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import dateFormatConvertor from './dateFormatConvertor' 2 | import fetchLocalData from './fetchLocalData' 3 | import fetchSidebarData from './fetchSidebarData' 4 | import htmlCleaner from './htmlCleaner' 5 | import stringCleaner from './stringCleaner' 6 | import { unicodeDecoder } from './unicodeDecoder' 7 | import fetchHomePageData from './fetchHomePageData' 8 | import normalizeAlbum from './normalizeAlbum' 9 | import normalizeTrack from './normalizeTrack' 10 | import transformDomain from './transformDomain' 11 | import documentTitle from './documentTitle' 12 | import deleteAllCookies from './deleteAllCookies' 13 | 14 | export { 15 | dateFormatConvertor, 16 | fetchLocalData, 17 | fetchSidebarData, 18 | htmlCleaner, 19 | stringCleaner, 20 | unicodeDecoder, 21 | fetchHomePageData, 22 | normalizeAlbum, 23 | normalizeTrack, 24 | transformDomain, 25 | documentTitle, 26 | deleteAllCookies, 27 | } 28 | -------------------------------------------------------------------------------- /src/APIs/trackApi.ts: -------------------------------------------------------------------------------- 1 | import { countries } from '@/types/countries' 2 | import { spotifyApiDev } from './axiosClient' 3 | 4 | interface GetTrackParams { 5 | id: string 6 | } 7 | 8 | export const getTrack = async (params: GetTrackParams) => { 9 | const { id } = params 10 | 11 | const { data } = await spotifyApiDev.get(`tracks/${id}`) 12 | 13 | return data 14 | } 15 | 16 | interface getTrackRecommendationParams { 17 | limit?: number 18 | market?: countries 19 | seed_artists: string 20 | seed_tracks?: string 21 | } 22 | 23 | export const getTrackRecommendation = async (params: getTrackRecommendationParams) => { 24 | const { limit = 19, market = 'VN', seed_artists, seed_tracks } = params 25 | const { data } = await spotifyApiDev.get('recommendations', { 26 | params: { 27 | limit, 28 | market, 29 | seed_artists, 30 | seed_tracks, 31 | }, 32 | }) 33 | 34 | return data.tracks 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/Genre/Genre.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .genre-wrapper { 5 | height: 100%; 6 | position: relative; 7 | overflow: hidden; 8 | background-color: $bg-base; 9 | } 10 | 11 | .body { 12 | height: 100%; 13 | overflow: hidden; 14 | overflow-y: scroll; 15 | padding-bottom: 32px; 16 | padding-top: $navbar-height; 17 | position: relative; 18 | 19 | &::-webkit-scrollbar { 20 | display: none; 21 | } 22 | } 23 | 24 | .main { 25 | position: relative; 26 | 27 | 28 | .bg-blur { 29 | position: absolute; 30 | height: 232px; 31 | top: 0; 32 | left: 0; 33 | right: 0; 34 | background-image: linear-gradient(rgba(0, 0, 0, 0.6) 0, $bg-base 100%), $bg-noise; 35 | z-index: 0; 36 | } 37 | 38 | 39 | .section { 40 | padding-top: 90px; 41 | } 42 | } 43 | 44 | .pivot-tracking { 45 | position: absolute; 46 | z-index: -999; 47 | } 48 | -------------------------------------------------------------------------------- /src/types/sidebar.ts: -------------------------------------------------------------------------------- 1 | import { AlbumItem } from './album' 2 | import { ArtistData, ArtistItem } from './artist' 3 | import { PlayListItem } from './playlist' 4 | 5 | export type LibSelection = { 6 | title: string 7 | id: string 8 | type: 'playlist' | 'album' | 'artist' 9 | active: boolean 10 | } 11 | 12 | export interface ResponseLibItem { 13 | id?: string 14 | owner?: { 15 | display_name?: string 16 | } 17 | artists?: ArtistData[] 18 | name?: string 19 | images?: { 20 | url?: string 21 | }[] 22 | } 23 | 24 | export interface SidebarItemProps { 25 | author?: string 26 | type?: 'playlist' | 'artist' | 'album' 27 | thumbnail?: string | undefined 28 | name?: string | undefined 29 | id?: string 30 | artists?: ArtistData[] 31 | } 32 | 33 | export interface LibDataItem extends PlayListItem, AlbumItem, ArtistItem { 34 | owner?: { 35 | display_name: string 36 | } 37 | artists?: ArtistData[] 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/fetchSidebarData.ts: -------------------------------------------------------------------------------- 1 | import { getUserAlbum, getUserPlaylist, getUserTopArtists } from '@/apis/userApi' 2 | 3 | interface argsProps { 4 | type: 'playlist' | 'album' | 'artist' 5 | userId: string 6 | } 7 | 8 | const fetchSidebarData = async (args: Partial) => { 9 | const { type, userId } = args 10 | 11 | switch (type) { 12 | case 'playlist': { 13 | if (!userId) return 14 | const data = await getUserPlaylist(userId as string) 15 | return data?.items 16 | } 17 | case 'artist': { 18 | const data = await getUserTopArtists() 19 | return data?.items 20 | } 21 | case 'album': { 22 | const data = await getUserAlbum() 23 | return data?.items 24 | } 25 | default: { 26 | if (!userId) return 27 | const data = await getUserPlaylist(userId as string) 28 | return data?.items 29 | } 30 | } 31 | } 32 | 33 | export default fetchSidebarData 34 | -------------------------------------------------------------------------------- /src/components/Footer/LinkGroup/LinkGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './LinkGroup.module.scss' 3 | import classNames from 'classnames/bind' 4 | 5 | const cx = classNames.bind(styles) 6 | 7 | interface LinkGroupProps { 8 | groupLink: { 9 | title: string 10 | links: { 11 | title: string 12 | href: string 13 | dataAttributes: { 'data-ga-category': string; 'data-ga-action': string } 14 | }[] 15 | } 16 | } 17 | 18 | const LinkGroup: React.FC = ({ groupLink }) => { 19 | return ( 20 |
21 |

{groupLink.title}

22 |
23 | {groupLink.links.map((item, index) => ( 24 | 25 | {item.title} 26 | 27 | ))} 28 |
29 |
30 | ) 31 | } 32 | 33 | export default LinkGroup 34 | -------------------------------------------------------------------------------- /src/layouts/TopLayout/TopLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react' 2 | import styles from './TopLayout.module.scss' 3 | import classNames from 'classnames/bind' 4 | import Split from 'react-split' 5 | import { Sidebar } from '@/components' 6 | import { MainLayoutProvider } from '@/contexts/MainLayoutContext' 7 | import { Outlet } from 'react-router-dom' 8 | 9 | const cx = classNames.bind(styles) 10 | 11 | const TopLayout: FC = () => { 12 | return ( 13 |
14 | 21 | 22 | 23 |
24 | 25 |
26 |
27 |
28 |
29 | ) 30 | } 31 | 32 | export default TopLayout 33 | -------------------------------------------------------------------------------- /src/utils/durationConvertor.ts: -------------------------------------------------------------------------------- 1 | interface durationConvertorParams { 2 | milliseconds?: number 3 | type?: 'short' | 'long' 4 | } 5 | 6 | const durationConvertor = (params: Partial): string => { 7 | const { milliseconds, type = 'short' } = params 8 | if (!milliseconds && milliseconds !== 0) return '' 9 | const totalSeconds = Math.floor(milliseconds / 1000) 10 | const hours = Math.floor(totalSeconds / 3600) 11 | const minutes = `${('0' + Math.floor((totalSeconds % 3600) / 60)).slice(-2)}` 12 | const seconds = `${('0' + (totalSeconds % 60)).slice(-2)}` 13 | 14 | if (type === 'short') { 15 | if (hours > 0) { 16 | return `${hours}:${minutes}:${seconds}` 17 | } 18 | 19 | return `${minutes}:${seconds}` 20 | } else { 21 | if (hours > 0) { 22 | return `${hours} hours ${minutes} min ${seconds} sec` 23 | } 24 | 25 | return `${minutes} min ${seconds} sec` 26 | } 27 | } 28 | 29 | export default durationConvertor 30 | -------------------------------------------------------------------------------- /src/APIs/authorizeApi.ts: -------------------------------------------------------------------------------- 1 | // import { scopes } from '@/config/spotify' 2 | // import { generateCodeChallenge, generateRandomString } from '@/utils' 3 | 4 | // const authorizeApi = async () => { 5 | // const clientId = import.meta.env.VITE_SPOTIFY_CLIENT_ID 6 | // const codeVerifier = generateRandomString(128) 7 | // const codeChallenge = await generateCodeChallenge(codeVerifier) 8 | // const state = generateRandomString(16) 9 | // const scope = scopes 10 | 11 | // localStorage.setItem('spotify_code_verifier', codeVerifier) 12 | 13 | // const args = new URLSearchParams({ 14 | // response_type: 'code', 15 | // client_id: clientId, 16 | // scope: scope, 17 | // redirect_uri: `${window.location.origin}/`, 18 | // state: state, 19 | // code_challenge_method: 'S256', 20 | // code_challenge: codeChallenge, 21 | // }) 22 | // window.location.replace('https://accounts.spotify.com/authorize?' + args) 23 | // } 24 | 25 | // export default authorizeApi 26 | -------------------------------------------------------------------------------- /src/components/SearchResult/SearchResult.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | 5 | 6 | .wrapper { 7 | display: flex; 8 | flex-direction: column; 9 | gap: $panel-gap; 10 | position: relative; 11 | } 12 | 13 | 14 | 15 | .search__kind { 16 | display: flex; 17 | gap: 10px; 18 | padding: 7px 24px 10px; 19 | margin-bottom: 4px; 20 | transform: translateZ(1px); 21 | background-color: $bg-base; 22 | z-index: 8; 23 | min-height: 52px; 24 | position: sticky; 25 | position: -webkit-sticky; 26 | top: 0; 27 | right: 0; 28 | left: 0; 29 | 30 | .btn { 31 | @include button-text(8px 12px, $bg-button, $bg-button-highlight); 32 | color: $white; 33 | font-size: 14px; 34 | border-radius: 32px; 35 | } 36 | 37 | .active { 38 | color: $black; 39 | @include button-text(8px 12px, $white, $white); 40 | } 41 | } 42 | 43 | 44 | .grid-md { 45 | grid-template-columns: 16px minmax(300px, 4fr) 2fr 50px !important; 46 | } -------------------------------------------------------------------------------- /src/APIs/handleRefreshToken.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export const handleRefreshToken = async () => { 4 | const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID 5 | const CLIENT_SECRET = import.meta.env.VITE_SPOTIFY_CLIENT_SECRET 6 | const refreshToken = localStorage.getItem('spotify_refresh_token') 7 | const url = 'https://accounts.spotify.com/api/token' 8 | const headers = { 9 | 'Content-Type': 'application/x-www-form-urlencoded', 10 | Authorization: 'Basic ' + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`), 11 | } 12 | const params = new URLSearchParams() 13 | params.append('grant_type', 'refresh_token') 14 | params.append('refresh_token', refreshToken as string) 15 | 16 | const { data } = await axios.post(url, params, { headers }) 17 | 18 | const currentTime = new Date() 19 | 20 | localStorage.setItem('spotify_access_token', data.access_token) 21 | localStorage.setItem('spotify_access_token_at', JSON.stringify(currentTime)) 22 | return data.access_token 23 | } 24 | -------------------------------------------------------------------------------- /src/types/show.ts: -------------------------------------------------------------------------------- 1 | import { ImageSource } from './others' 2 | 3 | export interface ShowItem { 4 | description?: string 5 | html_description?: string 6 | duration_ms?: number 7 | explicit?: boolean 8 | id?: string 9 | images?: ImageSource[] 10 | name?: string 11 | release_date?: string 12 | type?: string 13 | } 14 | 15 | export interface ShowData { 16 | description?: string 17 | html_description?: string 18 | episodes?: { 19 | items?: ShowItem[] 20 | } 21 | explicit?: boolean 22 | id?: string 23 | images?: ImageSource[] 24 | name?: string 25 | publisher?: string 26 | total_episodes?: number 27 | } 28 | 29 | export interface Episode extends ShowItem { 30 | show?: { 31 | id?: string 32 | images?: ImageSource[] 33 | name?: string 34 | publisher?: string 35 | } 36 | } 37 | 38 | export interface Episode extends ShowItem { 39 | show?: { 40 | id?: string 41 | images?: ImageSource[] 42 | name?: string 43 | publisher?: string 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/UIs/Range/Range.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | 3 | .wrapper { 4 | height: 4px; 5 | width: 100%; 6 | background-color: rgba($color: #fff, $alpha: 0.3); 7 | border-radius: 2px; 8 | position: relative; 9 | 10 | .process-bar { 11 | width: 100%; 12 | height: 100%; 13 | background-color: $white; 14 | border-radius: 50px; 15 | } 16 | 17 | &:hover .process-bar { 18 | background-color: $secondary; 19 | } 20 | 21 | &:hover .controls { 22 | opacity: 1; 23 | } 24 | } 25 | 26 | .controls { 27 | position: absolute; 28 | top: 0; 29 | width: 100%; 30 | height: 100%; 31 | right: 0; 32 | background-color: transparent; 33 | outline: none; 34 | appearance: none; 35 | cursor: pointer; 36 | opacity: 0; 37 | 38 | &::-webkit-slider-thumb { 39 | -webkit-appearance: none; 40 | appearance: none; 41 | width: 12px; 42 | height: 12px; 43 | background: $white; 44 | cursor: pointer; 45 | border-radius: 50%; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/components/ArtistModal/ExternalLinkItem/ExternalLinkItem.tsx: -------------------------------------------------------------------------------- 1 | // import {FC, useMemo} from 'react' 2 | // import styles from './ExternalLinkItem.module.scss' 3 | // import classNames from 'classnames/bind' 4 | // import {IoLogoFacebook, IoLogoInstagram, IoLogoTwitter } from 'react-icons/io' 5 | // import {BiLinkExternal} from 'react-icons/bi' 6 | // import { ExternalLink } from '@/types/artist' 7 | 8 | // const cx = classNames.bind(styles) 9 | 10 | 11 | 12 | // const ExternalLinkItem: FC = ({name, url}) => { 13 | 14 | 15 | // const icons = useMemo(() => { 16 | // return { 17 | // 'FACEBOOK': IoLogoFacebook, 18 | // 'INSTAGRAM': IoLogoInstagram, 19 | // 'TWITTER': IoLogoTwitter, 20 | // 'WIKIPEDIA': BiLinkExternal, 21 | // } 22 | // }, []) 23 | 24 | 25 | 26 | 27 | // return ( 28 | //
29 | //
30 | //
31 | //
32 | // ) 33 | // } 34 | 35 | // export default ExternalLinkItem -------------------------------------------------------------------------------- /src/components/Section/Section.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .wrapper { 4 | padding-inline: 24px; 5 | display: flex; 6 | flex-direction: column; 7 | margin-bottom: 22px; 8 | } 9 | 10 | .header { 11 | flex: 1; 12 | display: flex; 13 | justify-content: space-between; 14 | align-items: center; 15 | min-height: 67px; 16 | 17 | .heading { 18 | color: $white; 19 | font-size: 24px; 20 | font-weight: 700; 21 | margin: 8px 0 16px; 22 | } 23 | 24 | a { 25 | font-size: 14px; 26 | font-weight: 700; 27 | color: $text-neutral; 28 | 29 | &:hover { 30 | text-decoration: underline; 31 | } 32 | } 33 | } 34 | 35 | .body { 36 | overflow: hidden; 37 | display: grid; 38 | // grid-template-columns: repeat(4, 1fr); 39 | grid-column: 1; 40 | grid-gap: 24px; 41 | // max-height: 305px; 42 | align-content: stretch; 43 | } 44 | 45 | .un-clickable { 46 | &:hover { 47 | text-decoration: none !important; 48 | cursor: text; 49 | } 50 | pointer-events: none; 51 | } -------------------------------------------------------------------------------- /src/APIs/categoriesApi.ts: -------------------------------------------------------------------------------- 1 | import { spotifyApiDev } from './axiosClient' 2 | 3 | export const getCategories = async () => { 4 | const { data } = await spotifyApiDev.get('browse/categories', { 5 | params: { 6 | country: 'VN', 7 | limit: 50, 8 | }, 9 | }) 10 | 11 | return data 12 | } 13 | 14 | interface getCategoryPlaylistProps { 15 | id?: string 16 | limit?: number 17 | offset?: number 18 | } 19 | 20 | export const getCategoryPlaylist = async (params: getCategoryPlaylistProps) => { 21 | const { id, limit = 50, offset = 0 } = params 22 | if (!id) return 23 | 24 | const { data } = await spotifyApiDev.get(`browse/categories/${id}/playlists`, { 25 | params: { 26 | limit, 27 | country: 'VN', 28 | offset, 29 | }, 30 | }) 31 | 32 | return data 33 | } 34 | 35 | export const getCategoryInfo = async (params: getCategoryPlaylistProps) => { 36 | const { id } = params 37 | if (!id) return 38 | 39 | const { data } = await spotifyApiDev.get(`browse/categories/${id}`) 40 | 41 | return data 42 | } 43 | -------------------------------------------------------------------------------- /src/components/SearchBanner/BannerItem/BannerItem.tsx: -------------------------------------------------------------------------------- 1 | import { Image } from '@/components/UIs' 2 | import { SearchBannerItem } from '@/types/search' 3 | import classNames from 'classnames/bind' 4 | import React from 'react' 5 | import { Link } from 'react-router-dom' 6 | import styles from './BannerItem.module.scss' 7 | 8 | const cx = classNames.bind(styles) 9 | 10 | const BannerItem: React.FC = ({ title, imgUrl, id, bgColor }) => { 11 | return ( 12 | 13 |
14 |
15 |

19 |
20 |
21 | {title} 22 |
23 |
24 | 25 | ) 26 | } 27 | 28 | export default BannerItem 29 | -------------------------------------------------------------------------------- /src/scss/_variable.scss: -------------------------------------------------------------------------------- 1 | $black: #000; 2 | $white: #fff; 3 | $primary: #1ed760; 4 | $secondary: #1db954; 5 | $tertiary: #1dcf5d; 6 | 7 | $bg-base: #121212; 8 | $bg-highlight: #1a1a1a; 9 | $bg-elevated-base: #242424; 10 | $bg-elevated-highlight: #2a2a2a; 11 | $bg-card: #181818; 12 | $bg-card-highlight: #282828; 13 | $essential-subdued: #727272; 14 | $bg-button: rgba(255, 255, 255, 0.07); 15 | $bg-button-highlight: rgba(255, 255, 255, 0.1); 16 | $bg-noise: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48ZmlsdGVyIGlkPSJhIiB4PSIwIiB5PSIwIj48ZmVUdXJidWxlbmNlIGJhc2VGcmVxdWVuY3k9Ii43NSIgc3RpdGNoVGlsZXM9InN0aXRjaCIgdHlwZT0iZnJhY3RhbE5vaXNlIi8+PGZlQ29sb3JNYXRyaXggdHlwZT0ic2F0dXJhdGUiIHZhbHVlcz0iMCIvPjwvZmlsdGVyPjxwYXRoIGQ9Ik0wIDBoMzAwdjMwMEgweiIgZmlsdGVyPSJ1cmwoI2EpIiBvcGFjaXR5PSIuMDUiLz48L3N2Zz4=); 17 | 18 | $text-subdued: #a7a7a7; 19 | $text-neutral: #b3b3b3; 20 | 21 | $panel-gap: 8px; 22 | $row-gap: 16px; 23 | $col-gap: 24px; 24 | 25 | $panel-border-radius: 8px; 26 | 27 | $navbar-height: 64px; 28 | -------------------------------------------------------------------------------- /src/APIs/searchApi.ts: -------------------------------------------------------------------------------- 1 | import { countries } from '@/types/countries' 2 | import { spotifyApiDev } from './axiosClient' 3 | 4 | type SearchTypes = 5 | | 'all' 6 | | Array<'album' | 'artist' | 'playlist' | 'track' | 'show' | 'episode' | 'audiobook'> 7 | 8 | interface SearchArgs { 9 | query: string 10 | types?: SearchTypes 11 | limit?: number 12 | market?: countries 13 | } 14 | 15 | const searchApi = async (params: Partial) => { 16 | const { query = '', types = 'all', limit = 10, market = 'VN' } = params 17 | let typesParam: string 18 | 19 | if (types === 'all') { 20 | typesParam = 'album%2Cplaylist%2Ctrack%2Cartist%2Cshow%2Cepisode%2Caudiobook' 21 | } else { 22 | typesParam = types.map((type) => encodeURIComponent(type)).join('%2C') 23 | } 24 | 25 | const { data } = await spotifyApiDev('search', { 26 | params: { 27 | q: encodeURIComponent(query), 28 | type: typesParam, 29 | market: market, 30 | limit: limit, 31 | }, 32 | }) 33 | 34 | return data 35 | } 36 | 37 | export default searchApi 38 | -------------------------------------------------------------------------------- /src/hooks/useComponentSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, RefObject } from 'react' 2 | 3 | interface ComponentSize { 4 | height: number 5 | width: number 6 | } 7 | 8 | const useComponentSize = (ref: RefObject): ComponentSize => { 9 | const [componentSize, setComponentSize] = useState({ 10 | width: -1, 11 | height: -1, 12 | }) 13 | 14 | useEffect(() => { 15 | const handleResize = () => { 16 | if (ref.current) { 17 | const { clientHeight, clientWidth } = ref.current 18 | setComponentSize({ height: clientHeight, width: clientWidth }) 19 | } 20 | } 21 | 22 | handleResize() // Initial size 23 | 24 | const observer = new ResizeObserver(handleResize) 25 | if (ref.current) { 26 | observer.observe(ref.current) 27 | } 28 | 29 | return () => { 30 | if (ref.current) { 31 | observer.unobserve(ref.current) 32 | } 33 | } 34 | }, [ref.current?.clientHeight, ref.current?.clientHeight]) 35 | 36 | return componentSize 37 | } 38 | 39 | export default useComponentSize 40 | -------------------------------------------------------------------------------- /src/APIs/getRefreshToken.ts: -------------------------------------------------------------------------------- 1 | import { REDIRECT_URI } from '@/constants/auth' 2 | import axios from 'axios' 3 | 4 | export const getRefreshToken = async () => { 5 | const authCode = localStorage.getItem('spotify_auth_code') 6 | const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID 7 | const CLIENT_SECRET = import.meta.env.VITE_SPOTIFY_CLIENT_SECRET 8 | 9 | const params = new URLSearchParams() 10 | params.append('grant_type', 'authorization_code') 11 | params.append('code', authCode as string) 12 | params.append('redirect_uri', REDIRECT_URI) 13 | params.append('client_id', CLIENT_ID) 14 | params.append('client_secret', CLIENT_SECRET) 15 | 16 | try { 17 | const { data } = await axios.post('https://accounts.spotify.com/api/token', params) 18 | const currentTime = new Date() 19 | 20 | localStorage.setItem('spotify_refresh_token', data.refresh_token) 21 | localStorage.setItem('spotify_access_token', data.access_token) 22 | localStorage.setItem('spotify_access_token_at', JSON.stringify(currentTime)) 23 | return { status: true } 24 | } catch { 25 | return { status: false } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tuấn Đặng 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 | -------------------------------------------------------------------------------- /src/types/artist.ts: -------------------------------------------------------------------------------- 1 | import { ExtractedColors, Gallery, HeaderImage, ImageSource } from './others' 2 | 3 | export interface ArtistData { 4 | name: string 5 | id: string 6 | } 7 | 8 | export interface ArtistItem { 9 | name?: string 10 | imageUrl?: string 11 | id?: string 12 | images?: ImageSource[] 13 | } 14 | 15 | export interface ArtistProfile { 16 | id: string | undefined 17 | name: string | undefined 18 | bio: string | undefined 19 | isVerified: boolean | undefined 20 | biography?: { text: string } 21 | } 22 | 23 | export interface ArtistTopCity { 24 | city: string 25 | country: string 26 | numberOfListeners: number 27 | region: string 28 | } 29 | 30 | export interface ArtistStats { 31 | followers: number 32 | monthlyListeners: number 33 | topCities: { items: ArtistTopCity[] } 34 | } 35 | 36 | interface AvatarImage { 37 | sources: ImageSource[] 38 | extractedColors: ExtractedColors 39 | } 40 | 41 | export interface Visuals { 42 | avatarImage: AvatarImage 43 | gallery: Gallery 44 | headerImage: HeaderImage 45 | } 46 | 47 | export interface ExternalLink { 48 | name: string 49 | url: string 50 | } 51 | -------------------------------------------------------------------------------- /src/types/others.ts: -------------------------------------------------------------------------------- 1 | import { ArtistData } from "./artist" 2 | 3 | export interface ImageSource { 4 | url: string 5 | width: number 6 | height: number 7 | } 8 | 9 | export interface ColorRaw { 10 | hex: string 11 | } 12 | 13 | export interface ExtractedColors { 14 | colorRaw: ColorRaw 15 | } 16 | 17 | export interface GalleryItem { 18 | sources: ImageSource[] 19 | } 20 | 21 | export interface Gallery { 22 | items: GalleryItem[] 23 | } 24 | 25 | export interface AvatarImage { 26 | sources: ImageSource[] 27 | extractedColors: ExtractedColors 28 | } 29 | 30 | export interface HeaderImage { 31 | sources: ImageSource[] 32 | extractedColors: ExtractedColors 33 | } 34 | 35 | export interface HeaderProps { 36 | title?: string 37 | thumbnail?: string 38 | quantity?: number 39 | type?: 'Playlist' | 'album' | 'single' | 'compilation' | 'podcast' | 'episode' 40 | bgColor?: string 41 | desc?: string 42 | isLoading?: boolean 43 | artists?: ArtistData[] 44 | releaseDate?: string 45 | isWhiteColor?: boolean 46 | headerType?: 'playlist' | 'album' | 'show' | 'genre' 47 | publisher?: string 48 | showName?: string 49 | showId?: string 50 | } 51 | -------------------------------------------------------------------------------- /src/components/AudioPlayer/PlayerControl/PlayerControl.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .wrapper { 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | .buttons { 10 | display: flex; 11 | justify-content: center; 12 | gap: 12px; 13 | align-items: center; 14 | } 15 | 16 | .btn { 17 | @include reset-button; 18 | background-color: transparent; 19 | border: none; 20 | color: hsla(0, 0%, 100%, 0.7); 21 | cursor: pointer; 22 | 23 | &:hover { 24 | color: $white; 25 | } 26 | } 27 | 28 | .playback-bar { 29 | margin-top: 12px; 30 | display: flex; 31 | justify-content: space-between; 32 | align-items: center; 33 | gap: 8px; 34 | } 35 | 36 | .playback-position, 37 | .playback-duration { 38 | width: 40px; 39 | color: $text-subdued; 40 | font-size: 11px; 41 | line-height: 100%; 42 | display: flex; 43 | align-items: center; 44 | justify-content: center; 45 | } 46 | 47 | .range { 48 | flex: 1; 49 | } 50 | 51 | .active { 52 | @include button-active; 53 | 54 | color: $secondary !important; 55 | position: relative; 56 | 57 | &::after { 58 | bottom: -3px; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/SidebarItem/SidebarItem.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .sidebar-item { 5 | height: 64px; 6 | padding: 8px; 7 | align-items: inherit; 8 | gap: $panel-gap; 9 | border-radius: 6px; 10 | transition: all .1s ease; 11 | cursor: pointer; 12 | display: grid; 13 | grid-template-columns: 48px auto; 14 | 15 | 16 | &:hover { 17 | background-color: $bg-highlight; 18 | } 19 | 20 | .thumbnail { 21 | height: 100%; 22 | aspect-ratio: 1 / 1; 23 | object-fit: cover; 24 | border-radius: 4px; 25 | overflow: hidden; 26 | } 27 | 28 | .body { 29 | display: flex; 30 | flex-direction: column; 31 | justify-content: space-between; 32 | padding: 4px 0; 33 | overflow: hidden; 34 | 35 | .heading { 36 | font-size: 1.6rem; 37 | margin: 0; 38 | color: $white; 39 | font-weight: 400; 40 | white-space: nowrap; 41 | overflow: hidden; 42 | text-overflow: ellipsis; 43 | } 44 | 45 | .type { 46 | font-size: 1.2rem; 47 | font-weight: 400; 48 | color: $text-subdued; 49 | @include text-in-one-line; 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/components/SearchBanner/BannerItem/BannerItem.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .main { 5 | // width: 100%; 6 | // min-width: 150px; 7 | // max-width: 230px; 8 | aspect-ratio: 1 / 1; 9 | border-radius: $panel-border-radius; 10 | position: relative; 11 | overflow: hidden; 12 | } 13 | 14 | .wrapper { 15 | width: 100%; 16 | height: 100%; 17 | border-radius: $panel-border-radius; 18 | position: relative; 19 | overflow: hidden; 20 | 21 | .title { 22 | width: 100%; 23 | padding: 16px; 24 | overflow: hidden; 25 | 26 | &-text { 27 | margin: 0; 28 | font-size: 24px; 29 | text-decoration: none !important; 30 | color: #fff; 31 | overflow-wrap: break-word; 32 | } 33 | } 34 | 35 | .img { 36 | width: 100px; 37 | aspect-ratio: 1 / 1; 38 | position: absolute; 39 | rotate: 25deg; 40 | bottom: -14px; 41 | right: -10px; 42 | 43 | span { 44 | display: block; 45 | width: 100%; 46 | height: 100%; 47 | } 48 | 49 | img { 50 | width: 100%; 51 | height: 100%; 52 | object-fit: cover; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/components/SearchBanner/SearchBanner.tsx: -------------------------------------------------------------------------------- 1 | import { MainLayoutContext } from '@/contexts/MainLayoutContext' 2 | import { SearchContext } from '@/contexts/SearchContext' 3 | import classNames from 'classnames/bind' 4 | import React, { useContext } from 'react' 5 | import BannerItem from './BannerItem/BannerItem' 6 | import styles from './SearchBanner.module.scss' 7 | 8 | const cx = classNames.bind(styles) 9 | 10 | const SearchBanner: React.FC = () => { 11 | const { quantityCol } = useContext(MainLayoutContext) 12 | const { categoriesData } = useContext(SearchContext) 13 | 14 | return ( 15 |
16 |

Browse all

17 |
21 | {categoriesData?.map((item, index) => ( 22 | 29 | ))} 30 |
31 |
32 | ) 33 | } 34 | 35 | export default React.memo(SearchBanner) 36 | -------------------------------------------------------------------------------- /public/data/initSongs.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "initSong", 3 | "id": "00006", 4 | "data": [ 5 | { 6 | "title": "MAKING MY WAY", 7 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e020b303433c40b65a63dec0a04", 8 | "id": "6HGOxrNik4iPurqStawTFQ" 9 | }, 10 | { 11 | "title": "Muộn Rồi Mà Sao Còn", 12 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e0229f906fe7a60df7777b02ee1", 13 | "id": "5fFLotKS1286huYIMQHqz7" 14 | }, 15 | { 16 | "title": "Hãy Trao Cho Anh", 17 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e020ac09baba508700ed0b5d4e3", 18 | "id": "0f5yQttJS5nNxRAleF4kZO" 19 | }, 20 | { 21 | "title": "Chúng Ta Của Hiện Tại", 22 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e025888c34015bebbf123957f6d", 23 | "id": "17iGUekw5nFt5mIRJcUm3R" 24 | }, 25 | { 26 | "title": "CHẠY NGAY ĐI - Onionn Remix", 27 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e02754d0b74f5f7eb1f109114f3", 28 | "id": "3ZYrI2prWaAm1RbpCqZzlO" 29 | }, 30 | { 31 | "title": "Khuôn Mặt Đáng Thương", 32 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e02af31997b23b7e6e65de1816b", 33 | "id": "7hX5Of6NDiEaAR9zWsAeYY" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/components/SidebarItem/SidebarItem.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind' 2 | import { FC } from 'react' 3 | import { Link } from 'react-router-dom' 4 | import { Image, SubTitle, ThumbDefault } from '../UIs' 5 | import styles from './SidebarItem.module.scss' 6 | import { SidebarItemProps } from '@/types/sidebar' 7 | 8 | const cx = classNames.bind(styles) 9 | 10 | const SidebarItem: FC = (props) => { 11 | const { type, thumbnail, name, author, id, artists } = props 12 | 13 | const newType = (() => { 14 | if (type === 'playlist') return author 15 | if (type === 'artist') return 'Artist' 16 | if (type === 'album' && artists) return 17 | })() 18 | 19 | return ( 20 |
21 | 22 |
23 |
24 | {thumbnail ? {name} : } 25 |
26 |
27 |

{name}

28 | {newType} 29 |
30 |
31 | 32 |
33 | ) 34 | } 35 | 36 | export default SidebarItem 37 | -------------------------------------------------------------------------------- /src/APIs/getAccessToken.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { handleRefreshToken } from './handleRefreshToken' 3 | 4 | export const getAccessToken = async () => { 5 | const accessTokenAt = new Date( 6 | JSON.parse(localStorage.getItem('spotify_access_token_at') as string) 7 | ).getTime() 8 | const currentTime = new Date() 9 | 10 | if (currentTime.getTime() - accessTokenAt < 3600000) { 11 | return localStorage.getItem('spotify_access_token') 12 | } else { 13 | return await handleRefreshToken() 14 | } 15 | } 16 | 17 | export const getAccessTokenDev = async () => { 18 | const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID 19 | const CLIENT_SECRET = import.meta.env.VITE_SPOTIFY_CLIENT_SECRET 20 | const refreshTokenDev = import.meta.env.VITE_REFRESH_TOKEN_DEV 21 | const headers = { 22 | 'Content-Type': 'application/x-www-form-urlencoded', 23 | Authorization: 'Basic ' + btoa(`${CLIENT_ID}:${CLIENT_SECRET}`), 24 | } 25 | 26 | const params = new URLSearchParams() 27 | params.append('grant_type', 'refresh_token') 28 | params.append('refresh_token', refreshTokenDev as string) 29 | const url = 'https://accounts.spotify.com/api/token' 30 | 31 | const { data } = await axios.post(url, params, { headers }) 32 | 33 | return data.access_token 34 | } 35 | -------------------------------------------------------------------------------- /src/components/SongList/SongList.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .wrapper { 4 | position: relative; 5 | 6 | .freeze-top-row { 7 | display: grid; 8 | // grid-template-columns: 16px 6fr 4fr 3fr 50px; 9 | grid-template-columns: 16px minmax(300px, 6fr) 4fr 3fr 50px; 10 | grid-gap: 16px; 11 | padding-inline: 40px; 12 | margin-inline: -24px; 13 | height: 36px; 14 | border-bottom: 1px solid hsla(0, 0%, 100%, .1); 15 | align-items: center; 16 | position: -webkit-sticky; 17 | position: sticky; 18 | z-index: 7; 19 | transform: translateZ(10px); 20 | 21 | div { 22 | color: $text-neutral; 23 | font-size: 14px; 24 | } 25 | 26 | .clock-icon { 27 | display: flex; 28 | justify-content: center; 29 | transform: translateX(-5px); 30 | } 31 | } 32 | 33 | .songs { 34 | display: flex; 35 | flex-direction: column; 36 | padding-top: 16px; 37 | } 38 | 39 | .stuck { 40 | background-color: $bg-highlight; 41 | box-shadow: 0 -1px 0 0 #181818; 42 | -webkit-box-shadow: 0 -1px 0 0 #181818; 43 | } 44 | } 45 | 46 | .grid-md { 47 | grid-template-columns: 16px minmax(300px, 4fr) 2fr 50px !important; 48 | } 49 | 50 | .is-album-track { 51 | grid-template-columns: 16px 4fr 50px !important; 52 | } -------------------------------------------------------------------------------- /src/components/Footer/Footer.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/mixin'; 2 | @import '../../scss/variable'; 3 | 4 | .footer { 5 | display: flex; 6 | flex-direction: column; 7 | padding: 8px 32px 40px; 8 | margin-top: 40px; 9 | 10 | &__top { 11 | display: flex; 12 | justify-content: space-between; 13 | margin-top: 32px; 14 | 15 | &-links { 16 | display: flex; 17 | flex-wrap: wrap; 18 | } 19 | 20 | &-social-links { 21 | display: flex; 22 | column-gap: 16px; 23 | 24 | a { 25 | @include button-icon(40px, rgb(41, 41, 41), rgb(114, 114, 114)); 26 | 27 | .icon { 28 | color: $white; 29 | font-size: 18px; 30 | } 31 | } 32 | } 33 | 34 | } 35 | 36 | 37 | &__bottom { 38 | display: flex; 39 | justify-content: space-between; 40 | align-items: center; 41 | padding-top: 26px; 42 | border-top: 1px solid rgba($color: #fff, $alpha: .1); 43 | 44 | 45 | &-links { 46 | display: flex; 47 | column-gap: 16px; 48 | 49 | &-item { 50 | font-size: 14px; 51 | color: $text-subdued; 52 | text-decoration: none; 53 | 54 | &:hover { 55 | color: $white; 56 | } 57 | } 58 | } 59 | 60 | 61 | &-copyright { 62 | color: $text-subdued; 63 | font-size: 14px; 64 | } 65 | } 66 | } -------------------------------------------------------------------------------- /src/contexts/MainLayoutContext.tsx: -------------------------------------------------------------------------------- 1 | import useComponentSize from '@/hooks/useComponentSize' 2 | import React, { FC, createContext, useRef, useState, useEffect } from 'react' 3 | 4 | interface MainLayoutProps { 5 | children: React.ReactNode 6 | } 7 | 8 | interface MainLayoutContext { 9 | height: number 10 | width: number 11 | quantityCol: number 12 | } 13 | 14 | export const MainLayoutContext = createContext({} as MainLayoutContext) 15 | 16 | export const MainLayoutProvider: FC = ({ children }) => { 17 | const mainRef = useRef(null) 18 | const [quantityCol, setQuantityCol] = useState(4) 19 | 20 | const size = useComponentSize(mainRef) 21 | 22 | useEffect(() => { 23 | if (size.width < 548) setQuantityCol(2) 24 | else if (size.width < 748) setQuantityCol(3) 25 | else if (size.width < 1048) setQuantityCol(4) 26 | else if (size.width < 1248) setQuantityCol(5) 27 | else if (size.width < 1448) setQuantityCol(6) 28 | else if (size.width < 1648) setQuantityCol(7) 29 | else if (size.width < 1868) setQuantityCol(8) 30 | else setQuantityCol(9) 31 | }, [size.width]) 32 | 33 | return ( 34 |
35 | 36 | {children} 37 | 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/types/section.ts: -------------------------------------------------------------------------------- 1 | import { AlbumItem } from './album' 2 | import { ArtistData, ArtistItem, Visuals } from './artist' 3 | import { ImageSource } from './others' 4 | import { PlayListItem } from './playlist' 5 | 6 | export interface SectionItemI extends PlayListItem, ArtistItem, AlbumItem { 7 | isArtist?: boolean 8 | isLoading?: boolean 9 | columnWidth?: number 10 | dataType?: string 11 | artists?: ArtistData[] 12 | desc?: string 13 | publisher?: string 14 | dateAdd?: string 15 | type?: 'default' | 'playlist' | 'album' | 'artistList' | 'artist' | 'show' 16 | } 17 | 18 | export interface SectionProps { 19 | title?: string 20 | href?: string 21 | data?: ResponseSectionItem[] 22 | dataType?: string 23 | isFull?: boolean 24 | isClickable?: boolean 25 | hideHeader?: boolean 26 | type?: 'default' | 'playlist' | 'album' | 'artistList' | 'artist' | 'show' 27 | apiType?: 'spotify' | 'rapid' 28 | pageType?: 'section' | 'genre' 29 | } 30 | 31 | export interface ResponseSectionItem { 32 | id?: string 33 | name?: string 34 | images: ImageSource[] | any 35 | publisher?: string 36 | artists?: ArtistData[] 37 | description?: string 38 | owner?: { 39 | display_name: string 40 | } 41 | release_date?: string 42 | uri?: string 43 | visuals?: Visuals 44 | profile?: any 45 | releases?: any 46 | coverArt?: any 47 | date?: any 48 | albumId?: string 49 | } 50 | -------------------------------------------------------------------------------- /src/components/AboutShow/AboutShow.tsx: -------------------------------------------------------------------------------- 1 | import { useEllipsisVertical } from '@/hooks' 2 | import classNames from 'classnames/bind' 3 | import { FC, memo, useRef, useState } from 'react' 4 | import Skeleton from 'react-loading-skeleton' 5 | import styles from './AboutShow.module.scss' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | interface AboutShowProps { 10 | htmlDesc?: string 11 | isLoading?: boolean 12 | } 13 | 14 | const AboutShow: FC = ({ htmlDesc, isLoading }) => { 15 | const [isExpanded, setExpanded] = useState(false) 16 | 17 | const descRef = useRef() 18 | 19 | const isEllipsisActive = useEllipsisVertical(descRef.current) 20 | 21 | return ( 22 |
23 |

24 | {!isLoading ? 'About' : } 25 |

26 |
27 |
28 |
29 | {isEllipsisActive && ( 30 |
31 | 34 |
35 | )} 36 |
37 | ) 38 | } 39 | 40 | export default memo(AboutShow) 41 | -------------------------------------------------------------------------------- /src/components/UIs/Range/Range.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind' 2 | import React, { FC } from 'react' 3 | import styles from './Range.module.scss' 4 | 5 | const cx = classNames.bind(styles) 6 | 7 | interface RangeProps { 8 | maxValue?: number 9 | step?: number 10 | process?: number 11 | handleChange: (e: React.ChangeEvent) => void 12 | handleMouseUp: ( 13 | e?: React.MouseEvent | React.TouchEvent 14 | ) => void 15 | } 16 | 17 | const Range: FC = ({ 18 | maxValue, 19 | step, 20 | process, 21 | handleChange, 22 | handleMouseUp, 23 | }) => { 24 | return ( 25 |
26 |
27 |
35 |
36 | 47 |
48 | ) 49 | } 50 | 51 | export default Range 52 | -------------------------------------------------------------------------------- /src/components/SongItemTag/SongItemTag.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/mixin'; 2 | @import '../../scss/variable'; 3 | @import '../../scss/breakpoint'; 4 | 5 | .song-item-tag { 6 | background-color: rgba($color: #000000, $alpha: .3); 7 | height: 80px; 8 | display: flex; 9 | align-items: center; 10 | justify-content: space-between; 11 | border-radius: 4px; 12 | overflow: hidden; 13 | position: relative; 14 | color: $white; 15 | transition: all .3s ease; 16 | cursor: pointer; 17 | 18 | &:hover .body .play-btn { 19 | opacity: 1; 20 | } 21 | 22 | &:hover { 23 | background-color: $bg-card-highlight; 24 | } 25 | } 26 | 27 | .thumbnail { 28 | height: 100%; 29 | aspect-ratio: 1 / 1; 30 | 31 | img { 32 | height: 100%; 33 | width: 100%; 34 | object-fit: cover; 35 | } 36 | } 37 | 38 | .body { 39 | margin-inline: 16px; 40 | flex: 1; 41 | display: flex; 42 | align-items: center; 43 | justify-content: space-between; 44 | gap: 16px; 45 | position: relative; 46 | 47 | &-name { 48 | flex: 1; 49 | font-size: 16px; 50 | font-weight: 500; 51 | display: -webkit-box; 52 | -webkit-line-clamp: 2; 53 | -webkit-box-orient: vertical; 54 | overflow: hidden; 55 | font-family: CircularSp; 56 | font-weight: 700; 57 | } 58 | 59 | .play-btn { 60 | opacity: 0; 61 | transition: all .3s ease; 62 | } 63 | } 64 | 65 | .hidden { 66 | display: none !important; 67 | } -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/Playlist/Playlist.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .wrapper { 5 | height: 100%; 6 | position: relative; 7 | overflow: hidden; 8 | background-color: $bg-base; 9 | } 10 | 11 | 12 | .body { 13 | height: 100%; 14 | overflow: hidden; 15 | overflow-y: scroll; 16 | padding-bottom: 32px; 17 | padding-top: $navbar-height; 18 | position: relative; 19 | 20 | &::-webkit-scrollbar { 21 | display: none; 22 | } 23 | } 24 | 25 | .song-list { 26 | position: relative; 27 | 28 | .bg-blur { 29 | position: absolute; 30 | height: 232px; 31 | top: 0; 32 | left: 0; 33 | right: 0; 34 | background-image: linear-gradient(rgba(0, 0, 0, .6) 0, $bg-base 100%), $bg-noise; 35 | z-index: 0; 36 | } 37 | 38 | .main { 39 | position: relative; 40 | padding-inline: 24px; 41 | 42 | .action-bar { 43 | display: flex; 44 | position: relative; 45 | padding: 24px; 46 | align-items: center; 47 | gap: 32px; 48 | 49 | .heart { 50 | @include button-icon(42px, transparent, transparent); 51 | color: $text-subdued; 52 | transition: none; 53 | 54 | &:hover { 55 | color: $white; 56 | } 57 | } 58 | } 59 | 60 | } 61 | } 62 | 63 | .grid-md { 64 | grid-template-columns: 16px minmax(300px, 4fr) 2fr 50px !important; 65 | } 66 | 67 | .pivot-tracking { 68 | position: absolute; 69 | z-index: -999; 70 | } -------------------------------------------------------------------------------- /src/components/Sidebar/Nav/Nav.tsx: -------------------------------------------------------------------------------- 1 | import { HomeActiveIcon, HomeIcon, SearchActiveIcon, SearchIcon } from '@/assets/icons' 2 | import classNames from 'classnames/bind' 3 | import { useMemo } from 'react' 4 | import { Link, useLocation } from 'react-router-dom' 5 | import styles from './Nav.module.scss' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | const Nav = () => { 10 | const { pathname } = useLocation() 11 | 12 | const routes = useMemo( 13 | () => [ 14 | { 15 | name: 'Home', 16 | path: '/', 17 | active: pathname === '/', 18 | icons: [HomeActiveIcon, HomeIcon], 19 | }, 20 | { 21 | name: 'Search', 22 | path: '/search', 23 | active: pathname === '/search', 24 | icons: [SearchActiveIcon, SearchIcon], 25 | }, 26 | ], 27 | [pathname] 28 | ) 29 | 30 | return ( 31 |
32 | {routes.map((item, index) => { 33 | const ActiveIcon = item.icons[0] 34 | const InactiveIcon = item.icons[1] 35 | 36 | return ( 37 | 38 |
39 |
40 | {item.active ? : } 41 |
42 |

{item.name}

43 |
44 | 45 | ) 46 | })} 47 |
48 | ) 49 | } 50 | 51 | export default Nav 52 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { bottomLinks, socialNetworkLinks, topLinkGroups } from '@/constants' 2 | import classNames from 'classnames/bind' 3 | import { FC, memo } from 'react' 4 | import styles from './Footer.module.scss' 5 | import LinkGroup from './LinkGroup/LinkGroup' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | const Footer: FC = () => { 10 | return ( 11 |
12 | 29 | 41 |
42 | ) 43 | } 44 | 45 | export default memo(Footer) 46 | -------------------------------------------------------------------------------- /src/components/AboutArtist/AboutArtist.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .wrapper { 5 | padding-inline: $col-gap; 6 | margin: 12px 0; 7 | } 8 | 9 | .title { 10 | h2 { 11 | margin: 0; 12 | font-size: 24px; 13 | color: $white; 14 | } 15 | margin: 8px 0; 16 | } 17 | 18 | .body { 19 | background-position: center; 20 | background-repeat: no-repeat; 21 | background-size: cover; 22 | height: 516px; 23 | width: min(800px, 100%); 24 | border-radius: 8px; 25 | display: flex; 26 | align-items: flex-end; 27 | transform: scale(1); 28 | transform-origin: center; 29 | transition: all 0.25s ease; 30 | 31 | &:hover { 32 | transform: scale(1.01); 33 | } 34 | } 35 | 36 | .text-content { 37 | width: 80%; 38 | padding: 40px; 39 | 40 | .monthly-listener { 41 | font-size: 16px; 42 | margin: 8px 0; 43 | font-weight: 700; 44 | } 45 | 46 | .desc { 47 | color: $white; 48 | font-size: 16px; 49 | @include text-line-clamp(3); 50 | line-height: 1.3; 51 | text-shadow: 0px 0px 2px rgba(0, 0, 0, 0.2); 52 | 53 | a { 54 | color: $white; 55 | font-weight: 700; 56 | 57 | &:hover { 58 | text-decoration: underline; 59 | } 60 | } 61 | } 62 | } 63 | 64 | .playing-view { 65 | .body { 66 | height: 270px !important; 67 | width: 100% !important; 68 | flex: 1; 69 | } 70 | 71 | .text-content { 72 | width: 100% !important; 73 | padding: 16px 16px 26px !important; 74 | 75 | .desc { 76 | font-size: 14px; 77 | @include text-line-clamp(2); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/components/Alert/Alert.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .wrapper { 4 | background-color: $bg-base; 5 | height: 100vh; 6 | width: 100vw; 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | justify-content: center; 11 | position: absolute; 12 | top: 0; 13 | left: 0; 14 | z-index: 99999; 15 | } 16 | 17 | .logo { 18 | height: 60px; 19 | width: 100%; 20 | background-position: center; 21 | background-repeat: no-repeat; 22 | background-size: contain; 23 | } 24 | 25 | .body { 26 | display: flex; 27 | flex-direction: column; 28 | align-items: center; 29 | padding: 40px; 30 | 31 | &__heading { 32 | font-size: 48px; 33 | font-weight: 700; 34 | letter-spacing: -2px; 35 | margin: 4px 0 16px; 36 | } 37 | 38 | &__sub-heading { 39 | font-size: 16px; 40 | color: $text-subdued; 41 | margin: 0 0 40px; 42 | } 43 | 44 | &__home-btn { 45 | font-size: 16px; 46 | font-weight: 700; 47 | text-decoration: none; 48 | background: #fff; 49 | border: 1px solid #878787; 50 | border-radius: 48px; 51 | color: #000; 52 | display: inline-block; 53 | line-height: 24px; 54 | margin-bottom: 36px; 55 | padding: 12px 32px; 56 | text-align: center; 57 | white-space: nowrap; 58 | margin: 0 0 36px; 59 | 60 | &:hover { 61 | transform: scale(1.04); 62 | } 63 | } 64 | 65 | &__help { 66 | font-size: 16px; 67 | color: #fff; 68 | display: block; 69 | font-weight: 700; 70 | text-decoration: none; 71 | 72 | &:hover { 73 | text-decoration: underline; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import Section from './Section/Section' 2 | import Footer from './Footer/Footer' 3 | import Greeting from './Greeting/Greeting' 4 | import Header from './Header/Header' 5 | import Navbar from './Navbar/Navbar' 6 | import Alert from './Alert/Alert' 7 | import SearchBanner from './SearchBanner/SearchBanner' 8 | import SectionItem from './SectionItem/SectionItem' 9 | import Sidebar from './Sidebar/Sidebar' 10 | import SidebarItem from './SidebarItem/SidebarItem' 11 | import SongItem from './SongItem/SongItem' 12 | import SongItemTag from './SongItemTag/SongItemTag' 13 | import ArtistList from './Header/ArtistList/ArtistList' 14 | import SearchResult from './SearchResult/SearchResult' 15 | import SongList from './SongList/SongList' 16 | import TopTracks from './TopTracks/TopTracks' 17 | import Discography from './Discography/Discography' 18 | import AboutArtist from './AboutArtist/AboutArtist' 19 | import ArtistModal from './ArtistModal/ArtistModal' 20 | import AudioPlayer from './AudioPlayer/AudioPlayer' 21 | import AboutShow from './AboutShow/AboutShow' 22 | import ShowsList from './ShowsList/ShowsList' 23 | import ShowItem from './ShowItem/ShowItem' 24 | import PlayingView from './PlayingView/PlayingView' 25 | 26 | export { 27 | Section, 28 | Footer, 29 | Greeting, 30 | Header, 31 | Navbar, 32 | Alert, 33 | SearchBanner, 34 | SectionItem, 35 | Sidebar, 36 | SidebarItem, 37 | SongItem, 38 | SongItemTag, 39 | ArtistList, 40 | SearchResult, 41 | SongList, 42 | TopTracks, 43 | Discography, 44 | AboutArtist, 45 | ArtistModal, 46 | AudioPlayer, 47 | AboutShow, 48 | ShowsList, 49 | ShowItem, 50 | PlayingView, 51 | } 52 | -------------------------------------------------------------------------------- /src/components/ShowItem/ShowItem.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .main { 5 | border-top: 2px solid rgba($color: #fff, $alpha: 0.1); 6 | cursor: pointer; 7 | } 8 | 9 | .main:hover, 10 | .main:hover + .main { 11 | border-color: transparent; 12 | } 13 | 14 | .show-item-wrapper { 15 | display: flex; 16 | flex-direction: row; 17 | align-items: flex-start; 18 | color: $white; 19 | padding: 16px; 20 | 21 | &:hover { 22 | background-color: rgba($color: #fff, $alpha: 0.1); 23 | border-radius: 4px; 24 | } 25 | } 26 | 27 | .thumb { 28 | width: 112px; 29 | height: 112px; 30 | overflow: hidden; 31 | border-radius: 4px; 32 | margin-right: 24px; 33 | } 34 | 35 | .body { 36 | flex: 1; 37 | } 38 | 39 | .title { 40 | h4 { 41 | font-size: 16px; 42 | font-weight: 700; 43 | margin: 0; 44 | } 45 | 46 | &:hover { 47 | text-decoration: underline; 48 | } 49 | } 50 | 51 | .desc { 52 | font-size: 14px; 53 | color: $text-neutral; 54 | line-height: 1.5; 55 | margin: 10px 0 22px; 56 | @include text-line-clamp(2); 57 | } 58 | 59 | .action { 60 | display: flex; 61 | flex-direction: row; 62 | align-items: center; 63 | 64 | .play-btn { 65 | margin-right: 16px; 66 | } 67 | 68 | .release-date { 69 | display: flex; 70 | flex-direction: row; 71 | font-size: 14px; 72 | color: rgba($color: #fff, $alpha: 0.7); 73 | align-items: center; 74 | gap: 4px; 75 | letter-spacing: 1px; 76 | 77 | .dot { 78 | height: 3px; 79 | width: 3px; 80 | border-radius: 500px; 81 | background-color: rgba($color: #fff, $alpha: 0.7); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/UIs/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react' 2 | import { useInView } from 'react-intersection-observer' 3 | import Skeleton from 'react-loading-skeleton' 4 | import styles from './Image.module.scss' 5 | 6 | interface ImageLazyProps { 7 | src?: string 8 | alt?: string 9 | inclSkeleton?: boolean 10 | colorRaw?: string 11 | } 12 | 13 | const ImageLazy: FC = (props) => { 14 | const { src = '', alt = '', inclSkeleton = true, colorRaw = '' } = props 15 | const [isLoading, setLoading] = useState(true) 16 | const [mounted, setMounted] = useState(false) 17 | 18 | const { ref, inView } = useInView({ threshold: 0, triggerOnce: true }) 19 | 20 | useEffect(() => { 21 | if (inView && !mounted) { 22 | setMounted(true) 23 | } 24 | }, [inView]) 25 | 26 | useEffect(() => { 27 | if (mounted) { 28 | const img = new Image() 29 | img.src = src 30 | img.onload = () => { 31 | setLoading(false) 32 | } 33 | } 34 | }, [src, mounted]) 35 | 36 | return ( 37 |
38 |
39 | {inclSkeleton ? ( 40 | 41 | ) : ( 42 |
46 | )} 47 |
48 | {alt} 55 |
56 | ) 57 | } 58 | 59 | export default ImageLazy 60 | -------------------------------------------------------------------------------- /src/pages/Album/Album.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .wrapper { 5 | height: 100%; 6 | position: relative; 7 | overflow: hidden; 8 | background-color: $bg-base; 9 | } 10 | 11 | .body { 12 | height: 100%; 13 | overflow: hidden; 14 | overflow-y: scroll; 15 | padding-bottom: 32px; 16 | padding-top: $navbar-height; 17 | position: relative; 18 | 19 | &::-webkit-scrollbar { 20 | display: none; 21 | } 22 | } 23 | 24 | .song-list { 25 | position: relative; 26 | 27 | .bg-blur { 28 | position: absolute; 29 | height: 232px; 30 | top: 0; 31 | left: 0; 32 | right: 0; 33 | background-image: linear-gradient(rgba(0, 0, 0, 0.6) 0, $bg-base 100%), $bg-noise; 34 | z-index: 0; 35 | } 36 | 37 | .main { 38 | position: relative; 39 | padding-inline: 24px; 40 | 41 | .action-bar { 42 | display: flex; 43 | position: relative; 44 | padding: 24px; 45 | align-items: center; 46 | gap: 32px; 47 | 48 | .heart { 49 | @include button-icon(42px, transparent, transparent); 50 | color: $text-subdued; 51 | transition: none; 52 | 53 | &:hover { 54 | color: $white; 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | .copy-rights { 62 | display: flex; 63 | flex-direction: column; 64 | padding-inline: 24px; 65 | margin-top: 32px; 66 | line-height: 1.7; 67 | gap: 4px; 68 | 69 | p { 70 | color: $text-neutral; 71 | font-size: 11px; 72 | margin: 0; 73 | } 74 | 75 | .date { 76 | font-size: 14px; 77 | margin-bottom: 2px; 78 | } 79 | } 80 | 81 | .artist-albums { 82 | margin-top: 32px; 83 | } 84 | 85 | .pivot-tracking { 86 | position: absolute; 87 | z-index: -999; 88 | } 89 | -------------------------------------------------------------------------------- /src/components/AboutArtist/AboutArtist.tsx: -------------------------------------------------------------------------------- 1 | import { unicodeDecoder } from '@/utils' 2 | import classNames from 'classnames/bind' 3 | import { FC, memo, useContext } from 'react' 4 | import styles from './AboutArtist.module.scss' 5 | import { ArtistContext } from '@/contexts/ArtistContext' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | interface AboutArtistProps { 10 | stats?: any 11 | profile?: any 12 | visuals?: any 13 | isLoading?: boolean 14 | aboutImg?: string 15 | inclHeader?: boolean 16 | type?: 'default' | 'playing-view' 17 | } 18 | 19 | const AboutArtist: FC = ({ 20 | profile, 21 | stats, 22 | isLoading, 23 | aboutImg, 24 | inclHeader = true, 25 | type = 'default', 26 | }) => { 27 | const desc = unicodeDecoder(profile?.bio) 28 | const { setModalOpen } = useContext(ArtistContext) 29 | 30 | return ( 31 |
32 | {inclHeader && ( 33 |
34 |

About

35 |
36 | )} 37 |
setModalOpen(true)} 41 | > 42 |
43 | {!isLoading && ( 44 | <> 45 |
46 | {stats?.monthlyListeners?.toLocaleString()} monthly listeners 47 |
48 | 52 | 53 | )} 54 |
55 |
56 |
57 | ) 58 | } 59 | 60 | export default memo(AboutArtist) 61 | -------------------------------------------------------------------------------- /src/pages/Queue/Queue.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .queue-wrapper { 5 | height: 100%; 6 | position: relative; 7 | overflow: hidden; 8 | background-color: $bg-base; 9 | } 10 | 11 | .body { 12 | height: 100%; 13 | overflow: hidden; 14 | overflow-y: scroll; 15 | padding-bottom: 32px; 16 | padding-top: $navbar-height; 17 | position: relative; 18 | 19 | &::-webkit-scrollbar { 20 | display: none; 21 | } 22 | } 23 | 24 | .title { 25 | padding-inline: 24px; 26 | font-size: 24px; 27 | color: $white; 28 | margin-bottom: 24px; 29 | margin-top: 42px; 30 | } 31 | 32 | .now-playing { 33 | padding-inline: 24px; 34 | } 35 | 36 | 37 | .sub-title { 38 | font-size: 16px; 39 | color: $text-subdued; 40 | margin: 0; 41 | margin-bottom: 4px; 42 | } 43 | 44 | .queue-list { 45 | padding-inline: 24px; 46 | margin-top: 40px; 47 | } 48 | 49 | .queue-notify { 50 | height: 50vh; 51 | min-height: 300px; 52 | display: flex; 53 | flex-direction: column; 54 | align-items: center; 55 | justify-content: center; 56 | } 57 | 58 | .content { 59 | font-size: 32px; 60 | } 61 | 62 | .home-btn { 63 | font-size: 16px; 64 | font-weight: 700; 65 | text-decoration: none; 66 | background: #fff; 67 | border: 1px solid #878787; 68 | border-radius: 48px; 69 | color: #000; 70 | display: inline-block; 71 | line-height: 24px; 72 | margin-bottom: 36px; 73 | padding: 12px 32px; 74 | text-align: center; 75 | white-space: nowrap; 76 | margin: 0 0 36px; 77 | 78 | &:hover { 79 | transform: scale(1.04); 80 | } 81 | } 82 | 83 | .help { 84 | font-size: 16px; 85 | color: #fff; 86 | display: block; 87 | font-weight: 700; 88 | text-decoration: none; 89 | 90 | &:hover { 91 | text-decoration: underline; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/components/AudioPlayer/Left/Left.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .wrapper { 5 | min-width: 180px; 6 | display: flex; 7 | flex-direction: row; 8 | gap: 16px; 9 | overflow: hidden; 10 | justify-content: flex-start; 11 | overflow: hidden; 12 | } 13 | 14 | .thumb { 15 | height: 56px; 16 | width: 56px; 17 | aspect-ratio: 1 / 1; 18 | border-radius: 4px; 19 | overflow: hidden; 20 | 21 | .default-thumb { 22 | @include default-thumb; 23 | } 24 | 25 | img { 26 | height: 100%; 27 | width: 100%; 28 | object-fit: cover; 29 | } 30 | } 31 | 32 | .body { 33 | // flex: 1; 34 | overflow: hidden; 35 | max-width: calc(100% - 148px); 36 | 37 | & > div { 38 | height: 100%; 39 | display: flex; 40 | flex-direction: column; 41 | padding: 8px 0; 42 | justify-content: space-between; 43 | overflow: hidden; 44 | } 45 | 46 | .name { 47 | font-size: 14px; 48 | font-weight: 700; 49 | cursor: pointer; 50 | @include text-in-one-line; 51 | position: relative; 52 | 53 | *::-webkit-scrollbar { 54 | display: none; 55 | } 56 | 57 | .pivot { 58 | color: transparent; 59 | position: absolute; 60 | opacity: 0; 61 | z-index: -99; 62 | } 63 | 64 | span { 65 | margin-right: 40px; 66 | } 67 | 68 | a { 69 | color: $white; 70 | 71 | &:hover { 72 | text-decoration: underline; 73 | } 74 | } 75 | } 76 | 77 | .artists { 78 | @include text-in-one-line; 79 | display: flex; 80 | overflow: hidden; 81 | } 82 | } 83 | 84 | .icon { 85 | flex: 1; 86 | display: flex; 87 | align-items: center; 88 | color: hsla(0, 0%, 100%, 0.7); 89 | margin-inline: 4px 40px; 90 | 91 | &:hover { 92 | color: $white; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/components/PlayingView/NextSong/NextSong.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .next-song-wrapper { 5 | border-radius: 16px; 6 | padding: 16px; 7 | background-color: #232323; 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | 12 | .header { 13 | display: flex; 14 | flex-direction: row; 15 | justify-content: space-between; 16 | 17 | .title { 18 | font-size: 16px; 19 | font-weight: 700; 20 | color: $white; 21 | } 22 | 23 | .open-queue-btn { 24 | & > button { 25 | @include reset-button; 26 | background-color: transparent; 27 | font-weight: 700; 28 | font-size: 14px; 29 | color: $text-subdued; 30 | transform-origin: center; 31 | transition: all 0.1s ease; 32 | cursor: pointer; 33 | 34 | &:hover { 35 | color: $white; 36 | text-decoration: underline; 37 | transform: scale(1.04); 38 | } 39 | } 40 | } 41 | } 42 | 43 | .body { 44 | margin-top: 16px; 45 | } 46 | 47 | .next-song { 48 | display: flex; 49 | flex-direction: row; 50 | gap: 12px; 51 | height: 64px; 52 | padding: 8px; 53 | align-items: center; 54 | border-radius: 8px; 55 | overflow: hidden; 56 | 57 | &:hover { 58 | background-color: #393939; 59 | } 60 | 61 | .icon { 62 | width: 16px; 63 | } 64 | 65 | .thumb { 66 | height: 100%; 67 | aspect-ratio: 1 / 1; 68 | overflow: hidden; 69 | border-radius: 4px; 70 | } 71 | 72 | .content { 73 | display: flex; 74 | flex-direction: column; 75 | overflow: hidden; 76 | flex: 1; 77 | 78 | .name-track { 79 | font-size: 16px; 80 | color: $white; 81 | font-weight: 700; 82 | cursor: pointer; 83 | @include text-in-one-line; 84 | 85 | &:hover { 86 | text-decoration: underline; 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/pages/Search/Search.tsx: -------------------------------------------------------------------------------- 1 | import { Footer, Navbar, SearchBanner, SearchResult } from '@/components' 2 | import { PlayerContext } from '@/contexts/PlayerContext' 3 | import { SearchContext } from '@/contexts/SearchContext' 4 | import { documentTitle } from '@/utils' 5 | import classNames from 'classnames/bind' 6 | import React, { FC, useContext, useEffect, useState } from 'react' 7 | import styles from './Search.module.scss' 8 | 9 | const cx = classNames.bind(styles) 10 | 11 | interface SearchProps { 12 | children?: React.ReactNode 13 | } 14 | 15 | const Search: FC = () => { 16 | const { setQuery: setSearchQuery, query: searchQuery } = useContext(SearchContext) 17 | const { isPlaying, prevDocumentTitle } = useContext(PlayerContext) 18 | const [query, setQuery] = useState(searchQuery) 19 | const [debounceValue, setDebounceValue] = useState(searchQuery) 20 | 21 | useEffect(() => { 22 | if (isPlaying) { 23 | prevDocumentTitle.current = 'Spotify – Search' 24 | } else { 25 | documentTitle('Spotify – Search') 26 | } 27 | }, [isPlaying]) 28 | 29 | useEffect(() => { 30 | let timeoutId: any 31 | if (!query?.trim()) { 32 | setDebounceValue('') 33 | } else { 34 | timeoutId = setTimeout(() => { 35 | setDebounceValue(query?.trim()) 36 | }, 500) 37 | } 38 | 39 | return () => clearTimeout(timeoutId) 40 | }, [query]) 41 | 42 | useEffect(() => { 43 | setSearchQuery(debounceValue) 44 | }, [debounceValue]) 45 | 46 | return ( 47 |
48 | 49 |
50 | {debounceValue && } 51 |
52 | 53 |
54 |
55 |
56 |
57 | ) 58 | } 59 | 60 | export default Search 61 | -------------------------------------------------------------------------------- /src/components/TopTracks/TopTracks.tsx: -------------------------------------------------------------------------------- 1 | import { SpotifyTrack } from '@/types/track' 2 | import classNames from 'classnames/bind' 3 | import { FC, memo, useState } from 'react' 4 | import { SongItem } from '..' 5 | import styles from './TopTracks.module.scss' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | interface TopTrackProps { 10 | songList?: SpotifyTrack[] 11 | isLoading?: boolean 12 | } 13 | 14 | const TopTrack: FC = ({ songList, isLoading }) => { 15 | const [isLess, setLess] = useState(true) 16 | 17 | 18 | return ( 19 |
20 |
21 |

Popular

22 |
23 |
24 | {!isLoading 25 | ? songList 26 | ?.slice(0, isLess ? 5 : 10) 27 | .map((item, index: number) => ( 28 | 39 | )) 40 | : Array(10) 41 | .fill(0) 42 | ?.slice(0, isLess ? 5 : 10) 43 | .map((item: any, index: number) => ( 44 | 45 | ))} 46 |
47 | {songList?.length && songList?.length > 5 && ( 48 |
49 | 52 |
53 | )} 54 |
55 | ) 56 | } 57 | 58 | export default memo(TopTrack) 59 | -------------------------------------------------------------------------------- /src/contexts/SearchContext.tsx: -------------------------------------------------------------------------------- 1 | import searchApi from '@/apis/searchApi' 2 | import { REDIRECT_URI } from '@/constants/auth' 3 | import { SearchBannerItem } from '@/types/search' 4 | import { FC, ReactNode, createContext, useEffect, useRef, useState } from 'react' 5 | interface SearchProviderProps { 6 | children: ReactNode 7 | } 8 | 9 | interface SearchContext { 10 | setQuery: React.Dispatch> 11 | data: any 12 | query: string | undefined 13 | categoriesData: SearchBannerItem[] 14 | categoryRef: React.MutableRefObject 15 | isLoading: boolean 16 | } 17 | 18 | export const SearchContext = createContext({} as SearchContext) 19 | 20 | export const SearchProvider: FC = ({ children }) => { 21 | const [query, setQuery] = useState('') 22 | const [data, setData] = useState(null) 23 | const [categoriesData, setCategoriesData] = useState([]) 24 | const [isLoading, setLoading] = useState(true) 25 | 26 | const categoryRef = useRef('all') 27 | 28 | useEffect(() => { 29 | const fetchCategories = async () => { 30 | const response = await fetch(`${REDIRECT_URI}/data/bannerSearch.json`) 31 | const data = await response.json() 32 | setCategoriesData([...data]) 33 | } 34 | fetchCategories() 35 | }, []) 36 | 37 | useEffect(() => { 38 | const fetchData = async () => { 39 | const data = await searchApi({ 40 | query: query, 41 | market: 'VN', 42 | limit: 19, 43 | }) 44 | setData({ ...data }) 45 | setLoading(false) 46 | } 47 | if (query) { 48 | setData(null) 49 | setLoading(true) 50 | fetchData() 51 | } else { 52 | setData(null) 53 | } 54 | }, [query]) 55 | 56 | return ( 57 | 60 | {children} 61 | 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/components/Alert/Alert.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react' 2 | import styles from './Alert.module.scss' 3 | import classNames from 'classnames/bind' 4 | import logoImage from '@/assets/image/logo/logo.svg' 5 | import { useDocumentTitle } from 'usehooks-ts' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | interface AlertProps { 10 | type?: 'notfound' | 'wrong' | 'noSupportDevice' 11 | } 12 | 13 | const Alert: React.FC = (props) => { 14 | const { type = 'notfound' } = props 15 | 16 | const { documentTitle, message } = useMemo(() => { 17 | switch (type) { 18 | case 'notfound': 19 | return { 20 | documentTitle: 'Page not found', 21 | message: `We can't seem to find the page you are looking for.`, 22 | } 23 | case 'wrong': 24 | return { 25 | documentTitle: 'Oops!', 26 | message: `Sorry, we couldn't complete your request.\n Please try refreshing this page or contact us.`, 27 | } 28 | case 'noSupportDevice': 29 | return { 30 | documentTitle: 'Unsupported device!', 31 | message: 'Desktop supported only!', 32 | } 33 | } 34 | }, [type]) 35 | 36 | useDocumentTitle(documentTitle) 37 | return ( 38 |
39 |
40 |
41 |

42 | {type === 'notfound' ? 'Page not found' : 'Oops! Something went wrong'} 43 |

44 |

{message}

45 | {type !== 'noSupportDevice' && ( 46 | 47 | Home 48 | 49 | )} 50 | 55 | Help 56 | 57 |
58 |
59 | ) 60 | } 61 | 62 | export default Alert 63 | -------------------------------------------------------------------------------- /src/components/UIs/PlayButton/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState, memo } from 'react' 2 | import styles from './PlayButton.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { TbPlayerPlayFilled } from 'react-icons/tb' 5 | import { GiPauseButton } from 'react-icons/gi' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | interface PlayButtonProps { 10 | size: number 11 | fontSize?: number 12 | transitionDuration?: number //ms 13 | scaleHovering?: number 14 | bgColor?: string 15 | isPlay?: boolean 16 | } 17 | 18 | const PlayButton: FC = (props) => { 19 | const { 20 | size, 21 | fontSize, 22 | transitionDuration, 23 | scaleHovering, 24 | bgColor, 25 | isPlay = false, 26 | } = props 27 | const [isHovering, setHovering] = useState(false) 28 | 29 | return ( 30 |
setHovering(true)} 33 | onMouseLeave={() => setHovering(false)} 34 | > 35 | 63 |
64 | ) 65 | } 66 | 67 | export default memo(PlayButton) 68 | -------------------------------------------------------------------------------- /src/pages/Show/Show.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .show-wrapper { 5 | height: 100%; 6 | position: relative; 7 | overflow: hidden; 8 | background-color: $bg-base; 9 | } 10 | 11 | .body { 12 | height: 100%; 13 | overflow: hidden; 14 | overflow-y: scroll; 15 | padding-bottom: 32px; 16 | padding-top: $navbar-height; 17 | position: relative; 18 | 19 | &::-webkit-scrollbar { 20 | display: none; 21 | } 22 | } 23 | 24 | .pivot-tracking { 25 | position: absolute; 26 | z-index: -999; 27 | } 28 | 29 | .main { 30 | position: relative; 31 | } 32 | 33 | .bg-blur { 34 | position: absolute; 35 | height: 232px; 36 | top: 0; 37 | left: 0; 38 | right: 0; 39 | background-image: linear-gradient(rgba(0, 0, 0, 0.6) 0, $bg-base 100%), $bg-noise; 40 | z-index: 0; 41 | } 42 | 43 | .action-bar { 44 | display: flex; 45 | position: relative; 46 | padding: 24px; 47 | align-items: center; 48 | gap: 32px; 49 | 50 | .follow-btn { 51 | @include reset-button; 52 | padding: 8px 15px; 53 | font-size: 14px; 54 | font-weight: 700; 55 | background-color: transparent; 56 | color: $white; 57 | border: 1px solid #878787; 58 | border-radius: 500px; 59 | cursor: pointer; 60 | transition: all 33ms ease; 61 | 62 | &:hover { 63 | border-color: $white; 64 | transform: scale(1.04); 65 | } 66 | } 67 | } 68 | 69 | .content { 70 | position: relative; 71 | z-index: 7; 72 | display: flex; 73 | flex-direction: row; 74 | column-gap: 4%; 75 | padding-inline: 24px; 76 | 77 | .about { 78 | flex: 1; 79 | order: 2; 80 | padding-bottom: 20px; 81 | } 82 | 83 | .episodes-list { 84 | order: 1; 85 | flex: 2; 86 | } 87 | } 88 | 89 | .col-layout { 90 | .content { 91 | flex-direction: column; 92 | 93 | .about { 94 | order: 1; 95 | } 96 | 97 | .episodes-list { 98 | order: 2; 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/components/ArtistBanner/ArtistBanner.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .wrapper { 5 | position: relative; 6 | height: max(40vh, 330px); 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: flex-end; 10 | padding: 0 24px 24px; 11 | margin-top: -$navbar-height; 12 | background-color: transparent; 13 | } 14 | 15 | .blur { 16 | width: 100%; 17 | height: 100%; 18 | position: absolute; 19 | top: 0; 20 | right: 0; 21 | background: linear-gradient(transparent 0, rgba(0, 0, 0, .5) 100%), $bg-noise; 22 | z-index: 1; 23 | background-color: transparent; 24 | } 25 | 26 | .main { 27 | display: flex; 28 | transform: translateZ(10px); 29 | z-index: 7; 30 | align-items: flex-end; 31 | gap: $col-gap; 32 | } 33 | 34 | 35 | .avatar { 36 | height: 232px; 37 | aspect-ratio: 1 / 1; 38 | overflow: hidden; 39 | border-radius: 50%; 40 | box-shadow: 0 4px 60px rgba(0, 0, 0, .5); 41 | 42 | img { 43 | height: 100%; 44 | width: 100%; 45 | object-fit: cover; 46 | } 47 | } 48 | 49 | .info { 50 | display: flex; 51 | flex-direction: column; 52 | 53 | .verified { 54 | display: flex; 55 | align-items: center; 56 | gap: 8px; 57 | font-size: 14px; 58 | 59 | .icon { 60 | color: #3d91f4; 61 | position: relative; 62 | width: 22px; 63 | 64 | .check-white { 65 | position: absolute; 66 | width: 12px; 67 | height: 12px; 68 | background-color: $white; 69 | z-index: 0; 70 | top: -6px; 71 | right: 4px; 72 | } 73 | 74 | svg { 75 | position: absolute; 76 | z-index: 2; 77 | top: -12px; 78 | } 79 | } 80 | } 81 | 82 | .name { 83 | font-size: 96px; 84 | margin: 28px 0 22px; 85 | letter-spacing: -2px; 86 | line-height: 1.2; 87 | @include text-line-clamp(2); 88 | } 89 | 90 | .monthly-listener { 91 | font-size: 16px; 92 | margin: 8px 0; 93 | } 94 | } 95 | 96 | .no-header-img { 97 | height: clamp(340px, 30vh, 400px) !important; 98 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": { 3 | "name": "Tuan Dang", 4 | "email": "tuandangit2004@gmail.com", 5 | "url": "https://github.com/tuan204-dev" 6 | }, 7 | "name": "spotify-react", 8 | "description": "Spotify clone with React - TS", 9 | "private": true, 10 | "version": "0.0.0", 11 | "type": "module", 12 | "license": "MIT", 13 | "scripts": { 14 | "dev": "vite --port 5000 --host", 15 | "build": "tsc && vite build", 16 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 17 | "preview": "vite preview --port 5001 --host" 18 | }, 19 | "dependencies": { 20 | "@types/node": "^20.2.5", 21 | "antd": "^5.7.1", 22 | "axios": "^1.4.0", 23 | "classnames": "^2.3.2", 24 | "color-thief-react": "^2.1.0", 25 | "colorthief": "^2.4.0", 26 | "he": "^1.2.0", 27 | "normalize.css": "^8.0.1", 28 | "query-string": "^8.1.0", 29 | "react": "^18.2.0", 30 | "react-dom": "^18.2.0", 31 | "react-error-boundary": "^4.0.10", 32 | "react-fast-marquee": "^1.6.0", 33 | "react-icons": "^4.9.0", 34 | "react-intersection-observer": "^9.5.1", 35 | "react-lazy-load-image-component": "^1.6.0", 36 | "react-loading-skeleton": "^3.3.1", 37 | "react-router-dom": "^6.11.2", 38 | "react-spinners-kit": "^1.9.1", 39 | "react-split": "^2.0.14", 40 | "usehooks-ts": "^2.9.1" 41 | }, 42 | "devDependencies": { 43 | "@types/he": "^1.2.0", 44 | "@types/jsdom": "^21.1.1", 45 | "@types/react": "^18.0.37", 46 | "@types/react-dom": "^18.0.11", 47 | "@types/react-lazy-load-image-component": "^1.5.3", 48 | "@types/react-modal": "^3.16.0", 49 | "@typescript-eslint/eslint-plugin": "^5.59.0", 50 | "@typescript-eslint/parser": "^5.59.0", 51 | "@vitejs/plugin-react": "^4.0.0", 52 | "eslint": "^8.38.0", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "eslint-plugin-react-refresh": "^0.3.4", 55 | "typescript": "^5.0.2", 56 | "vite": "^4.3.9" 57 | }, 58 | "repository": { 59 | "type": "github", 60 | "url": "https://github.com/tuan204-dev/spotify-react-typescript" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/types/track.ts: -------------------------------------------------------------------------------- 1 | import { SpotifyAlbum } from './album' 2 | import { ArtistData } from './artist' 3 | import { ImageSource } from './others' 4 | 5 | export interface SongItemTagProps { 6 | thumbnailUrl?: string 7 | name?: string 8 | isLoading?: boolean 9 | id?: string 10 | setBgColor: React.Dispatch> 11 | albumId?: string 12 | } 13 | 14 | export interface SongItemProps { 15 | songName?: string 16 | artists?: ArtistData[] 17 | thumb?: string 18 | duration?: number 19 | order?: number 20 | isLoading?: boolean 21 | albumData?: SpotifyAlbum 22 | dateAdd?: string 23 | isExplicit?: boolean 24 | type?: 'default' | 'playlist' | 'album' | 'search' | 'artist' 25 | id?: string 26 | originalData?: SpotifyTrack 27 | isPlaying?: boolean 28 | } 29 | 30 | export interface SongListProps { 31 | songList?: SongItemProps[] | { track?: SpotifyTrack }[] 32 | pivotTop?: number 33 | top?: number 34 | isLoading?: boolean 35 | type?: 'default' | 'playlist' | 'album' | 'search' | 'artist' 36 | albumId?: string 37 | albumImages?: ImageSource[] 38 | inclHeader?: boolean 39 | albumName?: string 40 | albumType?: 41 | | 'album' 42 | | 'Playlist' 43 | | 'single' 44 | | 'compilation' 45 | | 'podcast' 46 | | 'episode' 47 | | undefined 48 | adjustOrder?: number 49 | } 50 | 51 | export interface SpotifyTrack { 52 | album?: SpotifyAlbum 53 | artists?: ArtistData[] 54 | explicit?: boolean 55 | duration_ms?: number 56 | name?: string 57 | id?: string 58 | popularity?: number 59 | } 60 | 61 | // -----------Rapid---------- 62 | 63 | export interface RapidTrack { 64 | soundcloudTrack?: { 65 | audio?: { 66 | quality?: string 67 | url?: string 68 | durationMs?: number 69 | durationText?: string 70 | }[] 71 | } 72 | } 73 | 74 | export interface RapidArtistTrack { 75 | uid?: string 76 | track?: { 77 | album?: any 78 | artists?: any 79 | id?: string 80 | name?: string 81 | uri?: string 82 | playcount?: string 83 | duration?: { 84 | totalMilliseconds?: number 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/components/ShowsList/ShowsList.tsx: -------------------------------------------------------------------------------- 1 | import { FC, memo, useState, useEffect } from 'react' 2 | import styles from './ShowsList.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { ShowData, ShowItem as ShowItemProps } from '@/types/show' 5 | import { ShowItem } from '..' 6 | import { useInView } from 'react-intersection-observer' 7 | 8 | const cx = classNames.bind(styles) 9 | 10 | interface ShowsListProps { 11 | data?: ShowItemProps[] 12 | originalData?: ShowData 13 | isLoading?: boolean 14 | } 15 | 16 | const ShowsList: FC = ({ data, isLoading, originalData }) => { 17 | const [renderNumb, setRenderNumb] = useState(() => { 18 | if ((data?.length ?? 0 < 9) && data?.length) { 19 | return data?.length 20 | } 21 | return 9 22 | }) 23 | 24 | const { ref, inView } = useInView({ threshold: 0 }) 25 | 26 | useEffect(() => { 27 | if (renderNumb === data?.length) { 28 | return 29 | } 30 | if (inView && data?.length && renderNumb + 10 > data?.length) { 31 | setRenderNumb(data.length) 32 | } else { 33 | setRenderNumb((prev) => prev + 10) 34 | } 35 | }, [inView]) 36 | 37 | return ( 38 |
39 |

All Episodes

40 |
41 | {!isLoading ? ( 42 | <> 43 | {data?.slice(0, renderNumb)?.map((item) => ( 44 | 54 | ))} 55 |
56 | 57 | ) : ( 58 | Array(5) 59 | .fill(0) 60 | .map((item, index) => ) 61 | )} 62 |
63 |
64 | ) 65 | } 66 | 67 | export default memo(ShowsList) 68 | -------------------------------------------------------------------------------- /public/data/00002.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Top mixes", 3 | "id": "00002", 4 | "dataType": "playlist", 5 | "data": [ 6 | { 7 | "title": "Pop Mix", 8 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/pop/4xnihxcoXWK3UqryOSnbw5/en/default", 9 | "id": "37i9dQZF1EQncLwOalG3K7" 10 | }, 11 | { 12 | "title": "R&B Mix", 13 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/r_and_b/7tYKF4w9nC0nq9CsPZTHyP/en/default", 14 | "id": "37i9dQZF1EQoqCH7BwIYb7" 15 | }, 16 | { 17 | "title": "RPT MCK Mix", 18 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/artist/1zSv9qZANOWB4HRE8sxeTL/vi/default", 19 | "id": "37i9dQZF1EIYlLpgHrOJax" 20 | }, 21 | { 22 | "title": "2010s Mix", 23 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/twenty_tens/5IH6FPUwQTxPSXurCrcIov/en/default", 24 | "id": "37i9dQZF1EQqedj0y9Uwvu" 25 | }, 26 | { 27 | "title": "Chill Mix", 28 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/chill/0wJWawRvX8K9joiK9QqkX5/en/default", 29 | "id": "37i9dQZF1EVHGWrwldPRtj" 30 | }, 31 | { 32 | "title": "K-Pop Mix", 33 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/k_pop/2AfmfGFbe0A0WsTYm0SDTx/en/default", 34 | "id": "37i9dQZF1EQpesGsmIyqcW" 35 | }, 36 | { 37 | "title": "Sơn Tùng M-TP Mix", 38 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/artist/5dfZ5uSmzR7VQK0udbAVpf/vi/default", 39 | "id": "37i9dQZF1EIYVFZyV5RRx0" 40 | }, 41 | { 42 | "title": "2000s Mix", 43 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/two_thousands/04gDigrS5kc9YWfZHwBETP/en/default", 44 | "id": "37i9dQZF1EQn4jwNIohw50" 45 | }, 46 | { 47 | "title": "Moody Mix", 48 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/moody/5IH6FPUwQTxPSXurCrcIov/en/default", 49 | "id": "37i9dQZF1EVKuMoAJjoTIw" 50 | }, 51 | { 52 | "title": "Hip Hop Mix", 53 | "imageUrl": "https://seed-mix-image.spotifycdn.com/v6/img/hip_hop/4grjJqg7iwQ8RKHs8d9Snh/en/default", 54 | "id": "37i9dQZF1EQnqst5TRi17F" 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/components/Sidebar/Library/Library.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/animations'; 3 | @import '../../../scss/mixin'; 4 | 5 | .lib { 6 | background-color: $bg-base; 7 | display: flex; 8 | flex-direction: column; 9 | border-radius: $panel-border-radius; 10 | height: 100%; 11 | overflow: hidden; 12 | } 13 | 14 | .playlist { 15 | display: flex; 16 | justify-content: space-between; 17 | align-items: center; 18 | padding: 8px 22px; 19 | font-size: 16px; 20 | color: $text-subdued; 21 | 22 | &-header { 23 | display: flex; 24 | align-items: center; 25 | gap: 8px; 26 | @include text-transition; 27 | 28 | &:hover { 29 | color: $white; 30 | } 31 | 32 | &-icon { 33 | font-size: 24px; 34 | transform: translateY(4px); 35 | } 36 | 37 | &-text { 38 | font-weight: 600; 39 | } 40 | } 41 | 42 | &-button { 43 | display: flex; 44 | gap: $panel-gap; 45 | 46 | button { 47 | @include button-icon(32px, transparent, $bg-highlight); 48 | color: $text-subdued; 49 | 50 | &:hover { 51 | color: $white; 52 | } 53 | } 54 | } 55 | } 56 | 57 | .selection { 58 | display: flex; 59 | padding: 8px 16px; 60 | font-size: 14px; 61 | gap: $panel-gap; 62 | 63 | button { 64 | @include button-text(6px 12px, $bg-button, $bg-button-highlight); 65 | color: $white; 66 | border-radius: 14px; 67 | } 68 | } 69 | 70 | .playlist-section { 71 | padding-inline: 8px; 72 | padding-top: 8px; 73 | overflow: hidden; 74 | overflow-y: scroll; 75 | 76 | &::-webkit-scrollbar { 77 | display: none; 78 | } 79 | } 80 | 81 | .bottom-shadow { 82 | box-shadow: 0 6px 10px rgba(0, 0, 0, 0.6); 83 | } 84 | 85 | .lib-notify { 86 | flex: 1; 87 | display: flex; 88 | justify-content: center; 89 | align-items: flex-start; 90 | margin-top: 40px; 91 | overflow: hidden; 92 | 93 | 94 | .content { 95 | font-size: 14px; 96 | color: $text-subdued; 97 | cursor: pointer; 98 | 99 | &:hover { 100 | color: $white; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /public/data/00001.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Trending now", 3 | "id": "00001", 4 | "dataType": "album", 5 | "data": [ 6 | { 7 | "title": "The Idol Episode 4 (Music from the HBO Original Series) by The Weeknd, JENNIE, Lily-Rose Depp", 8 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e02b0dd6a5cd1dec96c4119c262", 9 | "id": "7tzVd1fwkxsorytCBjEJkU" 10 | }, 11 | { 12 | "title": "Cái Đầu Tiên by Thắng", 13 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e02fa201fc6fbffdd089791821a", 14 | "id": "5jDZKqgoVRbob6A3omYTG5" 15 | }, 16 | { 17 | "title": "Rap Việt Mùa 3 (2023) - Tập 4 by RAP VIỆT\"", 18 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e02796df877653ca8f3b5f7cf87", 19 | "id": "5L4uvdh4HyCTBVK6z11jpp" 20 | }, 21 | { 22 | "title": "Over The Moon by MONSTAR", 23 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e0299276d38ab4b2720f14d1c69", 24 | "id": "4bC0p7T5Wy2kqCuQ0uK4D7" 25 | }, 26 | { 27 | "title": "Chơi Như Tụi Mỹ by Andree Right Hand\"", 28 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e02001da1cf1c6860392a1380af", 29 | "id": "7qPMuPHg2shbQIGyxOd09A" 30 | }, 31 | { 32 | "title": "Đi Về Nhà by Đen, JustaTee\"", 33 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e022a8efe3bfa6a605fcf863237", 34 | "id": "7C3WHnNZ5zcUutPtsB7KjD" 35 | }, 36 | { 37 | "title": "Mở Mắt by Lil Wuyn, Đen", 38 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e027d1cdf26b5a2b32cebc58673", 39 | "id": "3cOInDOxGhxcFM4fqbQjXb" 40 | }, 41 | { 42 | "title": "BADBYE by WEAN", 43 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e023554301cfe1463752565a605", 44 | "id": "32QMQlxOQ5tzhUflb30ATX" 45 | }, 46 | { 47 | "title": "Melo-Đi EP.04 by CARA, JSOL, Hoàng Duyên\"", 48 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e02041ed75f9d22673c5c23e436", 49 | "id": "0PXT06Gj4tM6wFGJbijhNX" 50 | }, 51 | { 52 | "title": "Đợi (2023 Version) by Vũ.\"", 53 | "imageUrl": "https://i.scdn.co/image/ab67616d00001e02385416f19c7245e71d058a7a", 54 | "id": "2vTdOr9up0LbB9pIoa70Dm" 55 | } 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/pages/Artist/Artist.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | 3 | .wrapper { 4 | height: 100%; 5 | position: relative; 6 | overflow: hidden; 7 | background-color: $bg-base; 8 | } 9 | 10 | .banner-img { 11 | width: 100%; 12 | position: absolute; 13 | // background-position: center; 14 | // background-size: cover; 15 | // background-repeat: no-repeat; 16 | z-index: 0; 17 | transition: all 1ms ease; 18 | transform-origin: center; 19 | 20 | & > img { 21 | width: 100%; 22 | height: 100%; 23 | object-fit: cover; 24 | animation: brighten-up 0.2s; 25 | } 26 | 27 | .overlay { 28 | position: absolute; 29 | top: 0; 30 | right: 0; 31 | bottom: 0; 32 | left: 0; 33 | background-image: linear-gradient(rgba(0, 0, 0, 0.6) 0, $bg-base 100%), $bg-noise; 34 | opacity: 0.25; 35 | } 36 | } 37 | 38 | .body { 39 | height: 100%; 40 | overflow: hidden; 41 | overflow-y: scroll; 42 | padding-bottom: 32px; 43 | padding-top: $navbar-height; 44 | position: relative; 45 | z-index: 7; 46 | 47 | &::-webkit-scrollbar { 48 | display: none; 49 | } 50 | } 51 | 52 | .bg-blur { 53 | position: absolute; 54 | height: 232px; 55 | left: 0; 56 | right: 0; 57 | background-image: linear-gradient(rgba(0, 0, 0, 0.6) 0, $bg-base 100%), $bg-noise; 58 | z-index: 0; 59 | } 60 | 61 | .action-bar { 62 | padding: 24px; 63 | display: flex; 64 | align-items: center; 65 | position: relative; 66 | z-index: 1; 67 | gap: 24px; 68 | 69 | .follow-btn { 70 | color: $white; 71 | background-color: transparent; 72 | border: 1px solid hsla(0, 0%, 100%, 0.3); 73 | border-radius: 4px; 74 | font-size: 12px; 75 | font-weight: 700; 76 | letter-spacing: 0.1em; 77 | padding: 7px 15px; 78 | text-transform: uppercase; 79 | cursor: pointer; 80 | 81 | &:hover { 82 | border-color: $white; 83 | } 84 | } 85 | } 86 | 87 | .pivot-tracking { 88 | position: absolute; 89 | z-index: -999; 90 | } 91 | 92 | @keyframes brighten-up { 93 | 0% { 94 | filter: brightness(0.3); 95 | } 96 | 97 | 100% { 98 | filter: brightness(1); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/scss/_mixin.scss: -------------------------------------------------------------------------------- 1 | @import './variable'; 2 | 3 | @mixin backgroundImg($url) { 4 | background-image: url($url); 5 | background-position: center; 6 | background-repeat: no-repeat; 7 | background-size: contain; 8 | } 9 | 10 | @mixin item-center { 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | } 15 | 16 | @mixin button-base { 17 | color: $black; 18 | border: none; 19 | cursor: pointer; 20 | transition: all ease-in-out 0.2s; 21 | } 22 | 23 | @mixin reset-button { 24 | border: none; 25 | outline: none; 26 | 27 | &:focus { 28 | outline: none; 29 | } 30 | } 31 | 32 | @mixin button-icon($size, $bg, $bg-highlight) { 33 | position: relative; 34 | width: $size; 35 | height: $size; 36 | border-radius: calc($size / 2); 37 | background-color: $bg; 38 | @include button-base(); 39 | @include item-center(); 40 | 41 | &:hover { 42 | background-color: $bg-highlight; 43 | } 44 | } 45 | 46 | @mixin button-text($padding, $bg, $bg-highlight) { 47 | @include button-base(); 48 | padding: $padding; 49 | background-color: $bg; 50 | 51 | &:hover { 52 | background-color: $bg-highlight; 53 | } 54 | } 55 | 56 | @mixin text-line-clamp($line-number) { 57 | display: -webkit-box; 58 | -webkit-line-clamp: $line-number; 59 | -webkit-box-orient: vertical; 60 | overflow: hidden; 61 | white-space: normal; 62 | } 63 | 64 | @mixin text-in-one-line { 65 | white-space: nowrap; 66 | overflow: hidden; 67 | text-overflow: ellipsis; 68 | } 69 | 70 | @mixin text-shadow { 71 | text-shadow: 0px 0px 2px #7d7d7d; 72 | } 73 | 74 | @mixin button-active { 75 | color: $secondary !important; 76 | position: relative; 77 | 78 | &::after { 79 | content: ''; 80 | height: 4px; 81 | width: 4px; 82 | position: absolute; 83 | background-color: $secondary; 84 | border-radius: 50%; 85 | bottom: 4px; 86 | left: 50%; 87 | transform: translateX(-50%); 88 | } 89 | 90 | &:hover { 91 | color: $primary; 92 | } 93 | } 94 | 95 | @mixin default-thumb { 96 | @include item-center(); 97 | width: 100%; 98 | height: 100%; 99 | background-color: $bg-card-highlight; 100 | } 101 | -------------------------------------------------------------------------------- /src/components/ArtistBanner/ArtistBanner.tsx: -------------------------------------------------------------------------------- 1 | import { Verified } from '@/assets/icons' 2 | import classNames from 'classnames/bind' 3 | import { FC } from 'react' 4 | import styles from './ArtistBanner.module.scss' 5 | import { Image } from '../UIs' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | interface ArtistBannerProps { 10 | name?: string 11 | avatar?: string 12 | followerNumber?: number 13 | dominantColor?: string 14 | monthlyListeners?: number 15 | bgBannerOpacity?: number 16 | isVerified?: boolean 17 | inclHeaderImg?: boolean 18 | isLoading?: boolean 19 | } 20 | 21 | const ArtistBanner: FC = (props) => { 22 | const { 23 | name, 24 | monthlyListeners, 25 | dominantColor, 26 | bgBannerOpacity, 27 | isVerified, 28 | inclHeaderImg, 29 | avatar, 30 | isLoading, 31 | } = props 32 | 33 | return ( 34 |
35 | {!isLoading && ( 36 |
37 | {!inclHeaderImg && ( 38 |
39 | {/* avt */} 40 | avt 41 |
42 | )} 43 |
44 | {isVerified && ( 45 |
46 | 47 | 48 |
49 |
50 | Verified Artist 51 |
52 | )} 53 |

{name}

54 | {!isLoading && ( 55 | 56 | {monthlyListeners?.toLocaleString()} monthly listeners 57 | 58 | )} 59 |
60 |
61 | )} 62 |
69 |
70 | ) 71 | } 72 | 73 | export default ArtistBanner 74 | -------------------------------------------------------------------------------- /src/components/Discography/Discography.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind' 2 | import { FC, useMemo, useState } from 'react' 3 | import { Section } from '..' 4 | import styles from './Discography.module.scss' 5 | 6 | const cx = classNames.bind(styles) 7 | 8 | interface DiscographyProps { 9 | data: any 10 | } 11 | 12 | const Discography: FC = ({ data }) => { 13 | const [category, setCategory] = useState('popularReleases') 14 | 15 | const selection = useMemo( 16 | () => [ 17 | { 18 | key: 'popularReleases', 19 | display: 'Popular releases', 20 | active: 'popularReleases' === category, 21 | isExist: Boolean(data?.popularReleases?.length), 22 | }, 23 | 24 | { 25 | key: 'albums', 26 | display: 'Albums', 27 | active: 'albums' === category, 28 | isExist: Boolean(data?.albums?.length), 29 | }, 30 | { 31 | key: 'singles', 32 | display: 'Singles', 33 | active: 'singles' === category, 34 | isExist: Boolean(data?.singles?.length), 35 | }, 36 | ], 37 | [category, data] 38 | ) 39 | 40 | return ( 41 |
42 |
43 |

Discography

44 |
45 |
46 | {selection 47 | .filter((item) => item.isExist) 48 | .map((item) => ( 49 | 57 | ))} 58 |
59 |
60 |
71 |
72 |
73 | ) 74 | } 75 | 76 | export default Discography 77 | -------------------------------------------------------------------------------- /src/components/PlayingView/NextSong/NextSong.tsx: -------------------------------------------------------------------------------- 1 | import { SingleMusicNote } from '@/assets/icons' 2 | import { Image, SubTitle } from '@/components/UIs' 3 | import { PlayerContext } from '@/contexts/PlayerContext' 4 | import { SpotifyTrack } from '@/types/track' 5 | import classNames from 'classnames/bind' 6 | import { FC, useContext } from 'react' 7 | import { Link } from 'react-router-dom' 8 | import styles from './NextSong.module.scss' 9 | 10 | const cx = classNames.bind(styles) 11 | 12 | interface NextSongProps { 13 | nextSong: SpotifyTrack 14 | } 15 | const NextSong: FC = ({ nextSong }) => { 16 | const { 17 | setQueue, 18 | setCurrentTrack, 19 | setCurrentTrackIndex, 20 | calNextTrackIndex, 21 | setPlayingType, 22 | } = useContext(PlayerContext) 23 | 24 | const handleClick = (e: React.MouseEvent) => { 25 | e.stopPropagation() 26 | setCurrentTrack({ ...nextSong }) 27 | setQueue([{ ...nextSong }]) 28 | setCurrentTrackIndex(0) 29 | calNextTrackIndex() 30 | setPlayingType('track') 31 | } 32 | return ( 33 |
34 |
35 | Next in queue 36 | 37 |
38 | 39 |
40 | 41 |
42 |
43 |
handleClick(e)} className={cx('next-song')}> 44 |
45 | 46 |
47 |
48 | {nextSong?.name} 49 |
50 |
51 | 52 | {nextSong?.name} 53 | 54 | 55 | 56 | 57 |
58 |
59 |
60 |
61 | ) 62 | } 63 | 64 | export default NextSong 65 | -------------------------------------------------------------------------------- /src/assets/image/logo/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /src/components/SearchResult/TopResult/TopResult.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../scss/variable'; 2 | @import '../../../scss/mixin'; 3 | 4 | .wrapper { 5 | width: 100%; 6 | padding-inline: 24px; 7 | display: flex; 8 | justify-content: space-between; 9 | gap: 48px; 10 | margin-bottom: 28px; 11 | } 12 | 13 | .left, 14 | .right { 15 | .header { 16 | margin: 6px 0 22px; 17 | 18 | .heading { 19 | font-size: 24px; 20 | font-weight: 700; 21 | margin: 0; 22 | } 23 | } 24 | } 25 | 26 | .right { 27 | flex: 6; 28 | } 29 | 30 | .left { 31 | flex: 4; 32 | 33 | .body { 34 | padding: 20px; 35 | display: flex; 36 | flex-direction: column; 37 | position: relative; 38 | background-color: $bg-card; 39 | transition: background-color 0.3s ease; 40 | border-radius: $panel-border-radius; 41 | gap: 14px; 42 | max-width: 500px; 43 | overflow: hidden; 44 | 45 | .thumb { 46 | height: 92px; 47 | width: 92px; 48 | overflow: hidden; 49 | border-radius: 4px; 50 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); 51 | 52 | img { 53 | height: 100%; 54 | width: 100%; 55 | object-fit: cover; 56 | } 57 | 58 | .default-thumb { 59 | @include default-thumb; 60 | background-color: #333; 61 | } 62 | } 63 | 64 | .main { 65 | min-height: 62px; 66 | // max-width: 100%; 67 | // overflow: hidden; 68 | 69 | .name { 70 | font-size: 32px; 71 | color: $white; 72 | font-weight: 700; 73 | margin: 10px 0 18px; 74 | 75 | h3 { 76 | padding: 0; 77 | margin: 0; 78 | @include text-in-one-line; 79 | } 80 | } 81 | } 82 | 83 | .btn-pivot { 84 | position: absolute; 85 | bottom: 70px; 86 | right: 70px; 87 | 88 | .play-btn { 89 | position: absolute; 90 | opacity: 0; 91 | transform: translateY(8px); 92 | transition: all 0.3s ease; 93 | } 94 | } 95 | 96 | &:hover { 97 | background-color: $bg-card-highlight; 98 | 99 | .play-btn { 100 | opacity: 1; 101 | transform: translateY(0); 102 | } 103 | } 104 | } 105 | } 106 | 107 | .responsive { 108 | flex-direction: column; 109 | } 110 | -------------------------------------------------------------------------------- /src/components/Header/ArtistList/ArtistList.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames/bind' 2 | import React, { Fragment } from 'react' 3 | import { Link } from 'react-router-dom' 4 | import styles from './ArtistList.module.scss' 5 | import { ArtistData } from '@/types/artist' 6 | 7 | const cx = classNames.bind(styles) 8 | 9 | interface ArtistListProps { 10 | data: ArtistData[] | null | string 11 | } 12 | 13 | const ArtistList: React.FC = (props) => { 14 | const { data } = props 15 | if (typeof data === 'string') { 16 | return
{data}
17 | } 18 | const renderData: any = [] 19 | if (data) { 20 | if (data.length === 1) { 21 | renderData.push( 22 | 23 | {data[0]?.name} 24 | 25 | ) 26 | } else if (data?.length === 2) { 27 | renderData.push( 28 | <> 29 | 30 | {data[0]?.name} 31 | 32 | {', '} 33 | 34 | {data[1]?.name} 35 | 36 | 37 | ) 38 | } else { 39 | for (let i = 0; i < data.length - 2; i++) { 40 | renderData.push( 41 | 42 | 43 | {data[0]?.name} 44 | 45 | {', '} 46 | 47 | ) 48 | } 49 | renderData.push( 50 | 51 | 52 | 53 | {data[data.length - 2]?.name} 54 | 55 | 56 | {' and '} 57 | 58 | ) 59 | renderData.push( 60 | 64 | 65 | {data[data?.length - 1]?.name} 66 | 67 | 68 | ) 69 | } 70 | } 71 | 72 | 73 | return ( 74 |
75 | {(renderData?.length && renderData) || data} 76 |
77 |
78 | ) 79 | } 80 | 81 | export default ArtistList 82 | -------------------------------------------------------------------------------- /src/components/ArtistModal/ArtistModal.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/mixin'; 2 | @import '../../scss/variable'; 3 | 4 | .wrapper { 5 | position: fixed; 6 | top: 0; 7 | right: 0; 8 | bottom: 0; 9 | left: 0; 10 | z-index: 99; 11 | background-color: rgba($color: #000000, $alpha: 0.7); 12 | transition: all 0.4s ease; 13 | @include item-center; 14 | } 15 | 16 | .close-btn { 17 | position: absolute; 18 | z-index: 9999; 19 | top: 16px; 20 | right: 16px; 21 | opacity: 0.7; 22 | 23 | button { 24 | @include button-icon(32px, $bg-base, $bg-base); 25 | color: $white; 26 | } 27 | 28 | &:hover { 29 | opacity: 1; 30 | } 31 | } 32 | 33 | .modal { 34 | width: clamp(500px, 40vw, 768px); 35 | height: min(750px, 70vh); 36 | border-radius: $panel-border-radius; 37 | overflow: hidden; 38 | position: relative; 39 | transform-origin: bottom center; 40 | 41 | animation: zoomOut 0.4s ease; 42 | } 43 | 44 | .main { 45 | overflow: hidden; 46 | overflow-y: scroll; 47 | width: 100%; 48 | height: 100%; 49 | 50 | &::-webkit-scrollbar { 51 | display: none; 52 | } 53 | } 54 | 55 | .img { 56 | height: 56.25%; 57 | 58 | img { 59 | width: 100%; 60 | height: 100%; 61 | object-fit: cover; 62 | } 63 | } 64 | 65 | .body { 66 | background-color: $bg-base; 67 | display: flex; 68 | padding: 40px; 69 | } 70 | 71 | .right { 72 | .bio { 73 | color: $text-neutral; 74 | font-size: 16px; 75 | margin: 0; 76 | 77 | a { 78 | color: $text-neutral; 79 | font-weight: 700; 80 | 81 | &:hover { 82 | text-decoration: underline; 83 | } 84 | } 85 | } 86 | } 87 | 88 | .left { 89 | min-width: 170px; 90 | display: flex; 91 | flex-direction: column; 92 | 93 | .stats-listener { 94 | display: flex; 95 | flex-direction: column; 96 | margin: 0 40px 30px 0; 97 | 98 | .quantity { 99 | font-size: 32px; 100 | color: $white; 101 | font-weight: 700; 102 | margin: 14px 0; 103 | } 104 | 105 | .type { 106 | font-size: 14px; 107 | color: $text-neutral; 108 | } 109 | } 110 | } 111 | 112 | .close { 113 | opacity: 0; 114 | 115 | .modal { 116 | transition: all 0.3s ease; 117 | transform: scale(0.4); 118 | opacity: 0; 119 | } 120 | } 121 | 122 | @keyframes zoomOut { 123 | 0% { 124 | transform: scale(0.4); 125 | opacity: 0; 126 | } 127 | 128 | 100% { 129 | transform: scale(1); 130 | opacity: 1; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/APIs/axiosClient.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import queryString from 'query-string' 3 | import { getAccessToken, getAccessTokenDev } from './getAccessToken' 4 | 5 | export const spotifyApiClient = axios.create({ 6 | baseURL: 'https://api.spotify.com/v1', 7 | paramsSerializer: (params) => queryString.stringify(params, { encode: false }), 8 | }) 9 | 10 | spotifyApiClient.interceptors.request.use(async (config) => { 11 | config.headers['Content-Type'] = 'application/json' 12 | const isLogged = Boolean(localStorage.getItem('spotify_refresh_token')) 13 | 14 | if (isLogged) { 15 | const token = await getAccessToken() 16 | if (token) { 17 | config.headers.Authorization = `Bearer ${token}` 18 | } 19 | } else { 20 | const tokenDev = await getAccessTokenDev() 21 | config.headers.Authorization = `Bearer ${tokenDev}` 22 | } 23 | 24 | return config 25 | }) 26 | 27 | export const spotifyApiDev = axios.create({ 28 | baseURL: 'https://api.spotify.com/v1', 29 | paramsSerializer: (params) => queryString.stringify(params, { encode: false }), 30 | }) 31 | 32 | spotifyApiDev.interceptors.request.use(async (config) => { 33 | config.headers['Content-Type'] = 'application/json' 34 | 35 | const tokenDev = await getAccessTokenDev() 36 | config.headers.Authorization = `Bearer ${tokenDev}` 37 | 38 | return config 39 | }) 40 | 41 | //to get artists data 42 | export const rapidApiClient = axios.create({ 43 | baseURL: 'https://spotify23.p.rapidapi.com', 44 | paramsSerializer: (params) => queryString.stringify(params, { encode: false }), 45 | }) 46 | 47 | rapidApiClient.interceptors.request.use((config) => { 48 | const apiKey = import.meta.env.VITE_RAPID_SPOTIFY_API 49 | 50 | config.headers['X-RapidAPI-Key'] = apiKey 51 | config.headers['X-RapidAPI-Host'] = 'spotify23.p.rapidapi.com' 52 | 53 | return config 54 | }) 55 | 56 | // youtube search 57 | // export const youtubeApiClient = axios.create({ 58 | // baseURL: 'https://youtube.googleapis.com/youtube/v3', 59 | // paramsSerializer: (params) => queryString.stringify(params, { encode: true }), 60 | // }) 61 | 62 | //rapidApi - Youtube search https://rapidapi.com/fama-official-fastytapi/api/fastytapi/ 63 | // export const rapidYtSearchClient = axios.create({ 64 | // baseURL: 'https://fastytapi.p.rapidapi.com/ytapi', 65 | // paramsSerializer: (params) => queryString.stringify(params, { encode: true }), 66 | // }) 67 | 68 | // rapidYtSearchClient.interceptors.request.use((config) => { 69 | // const apiKey = import.meta.env.VITE_RAPID_YOUTUBE_SEARCH 70 | 71 | // config.headers['X-RapidAPI-Key'] = apiKey 72 | // config.headers['X-RapidAPI-Host'] = 'fastytapi.p.rapidapi.com' 73 | 74 | // return config 75 | // }) 76 | -------------------------------------------------------------------------------- /src/components/PlayingView/PlayingView.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .playing-view-wrapper { 5 | max-width: 400px; 6 | min-width: 300px; 7 | overflow: hidden; 8 | overflow-y: scroll; 9 | background-color: $bg-base; 10 | border-radius: $panel-border-radius; 11 | padding: 16px; 12 | 13 | &::-webkit-scrollbar { 14 | display: none; 15 | } 16 | 17 | & > div { 18 | display: flex; 19 | flex-direction: column; 20 | gap: 16px; 21 | } 22 | } 23 | 24 | .header { 25 | height: 32px; 26 | display: flex; 27 | flex-direction: row; 28 | justify-content: flex-end; 29 | 30 | .close-btn { 31 | width: 32px; 32 | height: 32px; 33 | border-radius: 50%; 34 | transition: all 0.2s ease; 35 | position: relative; 36 | 37 | button { 38 | position: absolute; 39 | top: 2px; 40 | right: 0px; 41 | @include reset-button; 42 | cursor: pointer; 43 | width: 100%; 44 | height: 100%; 45 | background-color: transparent; 46 | color: rgba($color: #fff, $alpha: 0.7); 47 | } 48 | 49 | &:hover { 50 | background-color: rgba($color: #fff, $alpha: 0.1); 51 | } 52 | } 53 | } 54 | 55 | .track-banner { 56 | width: 100%; 57 | aspect-ratio: 1 / 1; 58 | 59 | & > div { 60 | width: 100%; 61 | height: 100%; 62 | border-radius: 8px; 63 | overflow: hidden; 64 | } 65 | 66 | .default-banner { 67 | @include item-center; 68 | height: 100%; 69 | width: 100%; 70 | background-color: $bg-card-highlight; 71 | color: $white; 72 | } 73 | } 74 | 75 | .content { 76 | display: flex; 77 | flex-direction: row; 78 | align-items: center; 79 | justify-content: space-between; 80 | gap: 24px; 81 | width: 100%; 82 | height: 64px; 83 | 84 | .title { 85 | flex: 1; 86 | display: flex; 87 | flex-direction: column; 88 | overflow: hidden; 89 | 90 | .name-track { 91 | font-size: 24px; 92 | font-weight: 700; 93 | color: $white; 94 | margin: 0; 95 | padding-bottom: 8px; 96 | 97 | &:hover { 98 | text-decoration: underline; 99 | text-decoration-thickness: 2px; 100 | } 101 | } 102 | } 103 | 104 | .heart-btn { 105 | width: 30px; 106 | color: $text-subdued; 107 | cursor: pointer; 108 | 109 | &:hover { 110 | color: $white; 111 | } 112 | } 113 | } 114 | 115 | .about-artist { 116 | height: 270px; 117 | overflow: hidden; 118 | border-radius: 8px; 119 | 120 | & > div { 121 | margin-inline: -24px; 122 | overflow: hidden; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/hooks/useDominantColor.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react' 2 | 3 | const useDominantColor = (imageUrl: string | undefined): string => { 4 | const [dominantColor, setDominantColor] = useState('') 5 | 6 | useEffect(() => { 7 | if (!imageUrl) return 8 | 9 | const loadImage = async () => { 10 | try { 11 | const response = await fetch(imageUrl) 12 | const blob = await response.blob() 13 | const imageUrlObject = URL.createObjectURL(blob) 14 | 15 | const image = new Image() 16 | image.src = imageUrlObject 17 | image.onload = () => { 18 | const canvas = document.createElement('canvas') 19 | const ctx = canvas.getContext('2d') 20 | ctx?.drawImage(image, 0, 0) 21 | 22 | const imageData = ctx?.getImageData( 23 | 0, 24 | 0, 25 | canvas.width, 26 | canvas.height 27 | ).data 28 | 29 | // Process the image data to find the dominant color 30 | const color = getDominantColor(imageData) 31 | setDominantColor(color) 32 | } 33 | } catch (error) { 34 | console.error('Error loading the image:', error) 35 | } 36 | } 37 | 38 | loadImage() 39 | }, [imageUrl]) 40 | 41 | const getDominantColor = (imageData: Uint8ClampedArray | undefined): string => { 42 | // Your dominant color calculation logic goes here 43 | // You can use any color analysis algorithm of your choice 44 | // Here's a simple example that calculates the average RGB values 45 | 46 | let redSum = 0 47 | let greenSum = 0 48 | let blueSum = 0 49 | let pixelCount = 0 50 | 51 | for (let i = 0; i < imageData!.length; i += 4) { 52 | const red = imageData![i] 53 | const green = imageData![i + 1] 54 | const blue = imageData![i + 2] 55 | 56 | redSum += red 57 | greenSum += green 58 | blueSum += blue 59 | pixelCount++ 60 | } 61 | 62 | const averageRed = Math.round(redSum / pixelCount) 63 | const averageGreen = Math.round(greenSum / pixelCount) 64 | const averageBlue = Math.round(blueSum / pixelCount) 65 | 66 | return rgbToHex(averageRed, averageGreen, averageBlue) 67 | } 68 | 69 | const rgbToHex = (red: number, green: number, blue: number): string => { 70 | const toHex = (c: number) => { 71 | const hex = c.toString(16) 72 | return hex.length === 1 ? '0' + hex : hex 73 | } 74 | 75 | const hexRed = toHex(red) 76 | const hexGreen = toHex(green) 77 | const hexBlue = toHex(blue) 78 | 79 | return `#${hexRed}${hexGreen}${hexBlue}` 80 | } 81 | 82 | return dominantColor 83 | } 84 | 85 | export default useDominantColor 86 | -------------------------------------------------------------------------------- /src/components/SectionItem/SectionItem.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .wrapper { 5 | background-color: $bg-card; 6 | padding: 16px; 7 | border-radius: $panel-border-radius; 8 | width: 100%; 9 | height: 100%; 10 | transition: background-color 0.3s ease; 11 | position: relative; 12 | cursor: pointer; 13 | 14 | &:hover { 15 | background-color: $bg-card-highlight; 16 | } 17 | 18 | .img { 19 | width: 100%; 20 | aspect-ratio: 1 / 1; 21 | margin-bottom: 16px; 22 | border-radius: 6px; 23 | overflow: hidden; 24 | box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); 25 | 26 | span { 27 | display: block; 28 | width: 100%; 29 | height: 100%; 30 | } 31 | 32 | img { 33 | width: 100%; 34 | height: 100%; 35 | object-fit: cover; 36 | } 37 | 38 | .user-img-default { 39 | width: 100%; 40 | height: 100%; 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | background-color: #333; 45 | color: $text-neutral; 46 | } 47 | } 48 | 49 | .btn-pivot { 50 | width: 100%; 51 | height: 0; 52 | position: relative; 53 | 54 | .play-btn { 55 | position: absolute; 56 | z-index: 7; 57 | top: -66px; 58 | right: 8px; 59 | opacity: 0; 60 | transition: transform 0.3s ease, opacity 0.3s; 61 | } 62 | } 63 | 64 | .body { 65 | display: flex; 66 | flex-direction: column; 67 | overflow: hidden; 68 | position: relative; 69 | max-width: 100%; 70 | 71 | .heading { 72 | width: 100%; 73 | display: block; 74 | @include text-in-one-line; 75 | font-size: 16px; 76 | font-weight: 700; 77 | color: $white; 78 | margin: 0; 79 | padding-bottom: 4px; 80 | } 81 | 82 | .desc { 83 | font-size: 14px; 84 | color: $text-subdued; 85 | @include text-line-clamp(2); 86 | font-weight: 700; 87 | margin: 6px 0 4px; 88 | 89 | p { 90 | display: block; 91 | color: $text-subdued; 92 | 93 | // min-height: 32px; 94 | margin: 0; 95 | color: $text-subdued; 96 | line-height: 1.5; 97 | } 98 | 99 | a { 100 | color: $text-subdued; 101 | font-weight: 700; 102 | 103 | &:hover { 104 | text-decoration: underline; 105 | } 106 | } 107 | } 108 | } 109 | 110 | &:hover .play-btn { 111 | opacity: 1; 112 | transform: translateY(-8px); 113 | } 114 | } 115 | 116 | .isArtist { 117 | border-radius: 50% !important; 118 | } 119 | -------------------------------------------------------------------------------- /src/types/countries.ts: -------------------------------------------------------------------------------- 1 | export type countries = 2 | | 'AD' 3 | | 'AE' 4 | | 'AG' 5 | | 'AL' 6 | | 'AM' 7 | | 'AO' 8 | | 'AR' 9 | | 'AT' 10 | | 'AU' 11 | | 'AZ' 12 | | 'BA' 13 | | 'BB' 14 | | 'BD' 15 | | 'BE' 16 | | 'BF' 17 | | 'BG' 18 | | 'BH' 19 | | 'BI' 20 | | 'BJ' 21 | | 'BN' 22 | | 'BO' 23 | | 'BR' 24 | | 'BS' 25 | | 'BT' 26 | | 'BW' 27 | | 'BY' 28 | | 'BZ' 29 | | 'CA' 30 | | 'CD' 31 | | 'CG' 32 | | 'CH' 33 | | 'CI' 34 | | 'CL' 35 | | 'CM' 36 | | 'CO' 37 | | 'CR' 38 | | 'CV' 39 | | 'CW' 40 | | 'CY' 41 | | 'CZ' 42 | | 'DE' 43 | | 'DJ' 44 | | 'DK' 45 | | 'DM' 46 | | 'DO' 47 | | 'DZ' 48 | | 'EC' 49 | | 'EE' 50 | | 'EG' 51 | | 'ES' 52 | | 'ET' 53 | | 'FI' 54 | | 'FJ' 55 | | 'FM' 56 | | 'FR' 57 | | 'GA' 58 | | 'GB' 59 | | 'GD' 60 | | 'GE' 61 | | 'GH' 62 | | 'GM' 63 | | 'GN' 64 | | 'GQ' 65 | | 'GR' 66 | | 'GT' 67 | | 'GW' 68 | | 'GY' 69 | | 'HK' 70 | | 'HN' 71 | | 'HR' 72 | | 'HT' 73 | | 'HU' 74 | | 'ID' 75 | | 'IE' 76 | | 'IL' 77 | | 'IN' 78 | | 'IQ' 79 | | 'IS' 80 | | 'IT' 81 | | 'JM' 82 | | 'JO' 83 | | 'JP' 84 | | 'KE' 85 | | 'KG' 86 | | 'KH' 87 | | 'KI' 88 | | 'KM' 89 | | 'KN' 90 | | 'KR' 91 | | 'KW' 92 | | 'KZ' 93 | | 'LA' 94 | | 'LB' 95 | | 'LC' 96 | | 'LI' 97 | | 'LK' 98 | | 'LR' 99 | | 'LS' 100 | | 'LT' 101 | | 'LU' 102 | | 'LV' 103 | | 'LY' 104 | | 'MA' 105 | | 'MC' 106 | | 'MD' 107 | | 'ME' 108 | | 'MG' 109 | | 'MH' 110 | | 'MK' 111 | | 'ML' 112 | | 'MN' 113 | | 'MO' 114 | | 'MR' 115 | | 'MT' 116 | | 'MU' 117 | | 'MV' 118 | | 'MW' 119 | | 'MX' 120 | | 'MY' 121 | | 'MZ' 122 | | 'NA' 123 | | 'NE' 124 | | 'NG' 125 | | 'NI' 126 | | 'NL' 127 | | 'NO' 128 | | 'NP' 129 | | 'NR' 130 | | 'NZ' 131 | | 'OM' 132 | | 'PA' 133 | | 'PE' 134 | | 'PG' 135 | | 'PH' 136 | | 'PK' 137 | | 'PL' 138 | | 'PS' 139 | | 'PT' 140 | | 'PW' 141 | | 'PY' 142 | | 'QA' 143 | | 'RO' 144 | | 'RS' 145 | | 'RW' 146 | | 'SA' 147 | | 'SB' 148 | | 'SC' 149 | | 'SE' 150 | | 'SG' 151 | | 'SI' 152 | | 'SK' 153 | | 'SL' 154 | | 'SM' 155 | | 'SN' 156 | | 'SR' 157 | | 'ST' 158 | | 'SV' 159 | | 'SZ' 160 | | 'TD' 161 | | 'TG' 162 | | 'TH' 163 | | 'TJ' 164 | | 'TL' 165 | | 'TN' 166 | | 'TO' 167 | | 'TR' 168 | | 'TT' 169 | | 'TV' 170 | | 'TW' 171 | | 'TZ' 172 | | 'UA' 173 | | 'UG' 174 | | 'US' 175 | | 'UY' 176 | | 'UZ' 177 | | 'VC' 178 | | 'VE' 179 | | 'VN' 180 | | 'VU' 181 | | 'WS' 182 | | 'XK' 183 | | 'ZA' 184 | | 'ZM' 185 | | 'ZW' 186 | -------------------------------------------------------------------------------- /src/components/Greeting/Greeting.tsx: -------------------------------------------------------------------------------- 1 | import { HomePageContext } from '@/contexts/HomePageContext' 2 | import { MainLayoutContext } from '@/contexts/MainLayoutContext' 3 | import classNames from 'classnames/bind' 4 | import React, { FC, memo, useContext, useEffect, useState } from 'react' 5 | import Skeleton from 'react-loading-skeleton' 6 | import SongItemTag from '../SongItemTag/SongItemTag' 7 | import styles from './Greeting.module.scss' 8 | import { ResponseSectionItem } from '@/types/section' 9 | 10 | const cx = classNames.bind(styles) 11 | 12 | interface GreetingProps { 13 | bgColor?: string | null 14 | setBgColor: React.Dispatch> 15 | } 16 | 17 | const Greeting: FC = (props) => { 18 | const { bgColor, setBgColor } = props 19 | const [isLoading, setLoading] = useState(true) 20 | 21 | const { greetingAlbum } = useContext(HomePageContext) 22 | 23 | const { width } = useContext(MainLayoutContext) 24 | 25 | useEffect(() => { 26 | setBgColor('#e0e0e0') 27 | setLoading(greetingAlbum?.length === 0) 28 | }, [greetingAlbum]) 29 | 30 | const greeting = (): string => { 31 | const currentHour = new Date().getHours() 32 | if (5 <= currentHour && currentHour <= 11) return 'Good morning' 33 | if (12 <= currentHour && currentHour <= 17) return 'Good afternoon' 34 | return 'Good evening' 35 | } 36 | 37 | return ( 38 |
39 |
40 | {!isLoading ? ( 41 |

{greeting()}

42 | ) : ( 43 | 44 | )} 45 |
46 | 47 |
53 | {!isLoading 54 | ? greetingAlbum 55 | ?.slice(0, 6) 56 | .map((item: ResponseSectionItem, index) => ( 57 | 65 | )) 66 | : Array(6) 67 | .fill(0) 68 | .map((item, index) => ( 69 | 74 | ))} 75 |
76 |
77 | ) 78 | } 79 | 80 | export default memo(Greeting) 81 | -------------------------------------------------------------------------------- /src/layouts/RootLayout/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext } from 'react' 2 | import styles from './RootLayout.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { Outlet } from 'react-router-dom' 5 | import { SkeletonTheme } from 'react-loading-skeleton' 6 | import Split from 'react-split' 7 | import { PlayingView, Sidebar } from '@/components' 8 | import { MainLayoutProvider } from '@/contexts/MainLayoutContext' 9 | import { ArtistProvider } from '@/contexts/ArtistContext' 10 | import { SearchProvider } from '@/contexts/SearchContext' 11 | import { HomePageProvider } from '@/contexts/HomePageContext' 12 | import { AudioPlayer } from '@/components' 13 | import { PlayerProvider } from '@/contexts/PlayerContext' 14 | import { AuthProvider } from '@/contexts/AuthContext' 15 | import { AppContext } from '@/App' 16 | 17 | const cx = classNames.bind(styles) 18 | 19 | const RootLayout: FC = () => { 20 | const { isPlayingViewShowed } = useContext(AppContext) 21 | 22 | return ( 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 |
44 | 45 |
46 |
47 | {isPlayingViewShowed ? ( 48 | 49 | ) : ( 50 |
51 | )} 52 |
53 |
54 |
55 | 56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | ) 65 | } 66 | 67 | export default RootLayout 68 | -------------------------------------------------------------------------------- /src/contexts/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import { getRefreshToken } from '@/apis/getRefreshToken' 2 | import { getUserData } from '@/apis/userApi' 3 | import { END_POINT, REDIRECT_URI, RESPONSE_TYPE, SCOPE } from '@/constants/auth' 4 | import { UserData } from '@/types/user' 5 | import { FC, ReactNode, createContext, useEffect, useState } from 'react' 6 | import { useLocation, useNavigate } from 'react-router-dom' 7 | 8 | interface AuthProviderProps { 9 | children: ReactNode 10 | } 11 | 12 | interface AuthContext { 13 | isLogged: boolean 14 | userData?: UserData 15 | handleLogin: () => void 16 | handleLogout: () => void 17 | } 18 | 19 | export const AuthContext = createContext({} as AuthContext) 20 | 21 | export const AuthProvider: FC = ({ children }) => { 22 | const refreshToken = localStorage.getItem('spotify_refresh_token') 23 | const authCode = localStorage.getItem('spotify_auth_code') 24 | 25 | const [isLogged, setLogged] = useState(Boolean(refreshToken)) 26 | const [userData, setUserData] = useState({}) 27 | 28 | const navigate = useNavigate() 29 | const location = useLocation() 30 | 31 | useEffect(() => { 32 | const search = location.search.split('=') 33 | if (search[0] === '?code') { 34 | localStorage.setItem('spotify_auth_code', search[1]) 35 | navigate({ search: '' }) 36 | } 37 | }, []) 38 | 39 | useEffect(() => { 40 | if (authCode && !isLogged) { 41 | const handleAuth = async () => { 42 | const { status } = await getRefreshToken() 43 | if (status) { 44 | setLogged(true) 45 | } else setLogged(false) 46 | } 47 | handleAuth() 48 | } 49 | }, [authCode]) 50 | 51 | useEffect(() => { 52 | const fetchUserData = async () => { 53 | const data = await getUserData() 54 | setUserData(data) 55 | } 56 | if (isLogged) { 57 | fetchUserData() 58 | } 59 | }, [isLogged]) 60 | 61 | // handle login to get auth code 62 | const handleLogin = (): void => { 63 | const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID 64 | window.location.replace( 65 | `${END_POINT}?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=${RESPONSE_TYPE}&scope=${SCOPE}` 66 | ) 67 | } 68 | 69 | const handleLogout = () => { 70 | localStorage.removeItem('spotify_refresh_token') 71 | localStorage.removeItem('spotify_access_token') 72 | localStorage.removeItem('spotify_access_token_at') 73 | localStorage.removeItem('spotify_auth_code') 74 | localStorage.removeItem('spotify_current_track') 75 | setLogged(false) 76 | window.location.reload() 77 | } 78 | 79 | return ( 80 | 81 | {children} 82 | 83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, createContext, lazy, useEffect, useState } from 'react' 2 | import { Route, Routes } from 'react-router-dom' 3 | import LoadingLayout from './layouts/LoadingLayout/LoadingLayout' 4 | import { deleteAllCookies } from './utils' 5 | const RootLayout = lazy(() => import('./layouts/RootLayout/RootLayout')) 6 | const Home = lazy(() => import('@/pages/Home/Home')) 7 | const Playlist = lazy(() => import('@/pages/Playlist/Playlist')) 8 | const Album = lazy(() => import('@/pages/Album/Album')) 9 | const Artist = lazy(() => import('@/pages/Artist/Artist')) 10 | const Search = lazy(() => import('@/pages/Search/Search')) 11 | const Section = lazy(() => import('@/pages/Section/Section')) 12 | const Show = lazy(() => import('@/pages/Show/Show')) 13 | const Episode = lazy(() => import('@/pages/Episode/Episode')) 14 | const Alert = lazy(() => import('@/components/Alert/Alert')) 15 | const Queue = lazy(() => import('@/pages/Queue/Queue')) 16 | const Test = lazy(() => import('@/pages/test')) 17 | const Genre = lazy(() => import('@/pages/Genre/Genre')) 18 | 19 | interface AppContext { 20 | isPlayingViewShowed: boolean 21 | setPlayingViewShowed: React.Dispatch> 22 | } 23 | 24 | export const AppContext = createContext({} as AppContext) 25 | 26 | const App = () => { 27 | const [isPlayingViewShowed, setPlayingViewShowed] = useState(false) 28 | useEffect(() => { 29 | deleteAllCookies() 30 | }, []) 31 | 32 | if (window.innerWidth < 900) { 33 | return 34 | } 35 | 36 | return ( 37 | 38 | }> 39 | 40 | }> 41 | } /> 42 | } /> 43 | } /> 44 | } /> 45 | } /> 46 | } /> 47 | } /> 48 | } /> 49 | } /> 50 | } /> 51 | 52 | } /> 53 | } /> 54 | } /> 55 | } /> 56 | } /> 57 | } /> 58 | 59 | 60 | } /> 61 | 62 | 63 | 64 | ) 65 | } 66 | 67 | export default App 68 | -------------------------------------------------------------------------------- /src/components/ArtistModal/ArtistModal.tsx: -------------------------------------------------------------------------------- 1 | import { CloseIcon } from '@/assets/icons' 2 | import classNames from 'classnames/bind' 3 | import { FC, useEffect, useRef, useState } from 'react' 4 | import ArtistCityStats from '../UIs/ArtistCityStats/ArtistCityStats' 5 | import styles from './ArtistModal.module.scss' 6 | import { ArtistProfile, ArtistStats } from '@/types/artist' 7 | 8 | const cx = classNames.bind(styles) 9 | 10 | interface ArtistModalProps { 11 | profile: ArtistProfile 12 | aboutImg: string 13 | stats: ArtistStats 14 | setModalOpen: React.Dispatch> 15 | } 16 | 17 | const ArtistModal: FC = ({ 18 | profile, 19 | aboutImg, 20 | stats, 21 | setModalOpen, 22 | }) => { 23 | const timeoutId = useRef() 24 | const [isClose, setClose] = useState(false) 25 | 26 | const handleKeyPress = (e: any) => { 27 | if (e.code === 'Escape') { 28 | handleClose() 29 | } 30 | } 31 | 32 | useEffect(() => { 33 | document.addEventListener('keydown', handleKeyPress) 34 | 35 | return () => { 36 | clearTimeout(timeoutId.current) 37 | document.removeEventListener('keypress', handleKeyPress) 38 | } 39 | }, []) 40 | 41 | const handleClose = () => { 42 | setClose(true) 43 | timeoutId.current = setTimeout(() => { 44 | setModalOpen(false) 45 | }, 400) 46 | } 47 | 48 | console.log(profile) 49 | 50 | return ( 51 |
52 |
e.stopPropagation()}> 53 |
54 | 57 |
58 |
59 |
60 | {profile?.name} 61 |
62 |
63 |
64 |
65 |
{stats?.followers?.toLocaleString()}
66 | Followers 67 |
68 |
69 |
70 | {stats?.monthlyListeners?.toLocaleString()} 71 |
72 | Monthly Listeners 73 |
74 | {stats?.topCities?.items?.map((item) => ( 75 | 76 | ))} 77 |
78 |
79 |

85 |
86 |
87 |
88 |
89 |
90 | ) 91 | } 92 | 93 | export default ArtistModal 94 | -------------------------------------------------------------------------------- /src/pages/Queue/Queue.tsx: -------------------------------------------------------------------------------- 1 | import { Footer, Navbar, SongItem, SongList } from '@/components' 2 | import { PlayerContext } from '@/contexts/PlayerContext' 3 | import { documentTitle } from '@/utils' 4 | import classNames from 'classnames/bind' 5 | import { FC, useContext, useEffect } from 'react' 6 | import { Link } from 'react-router-dom' 7 | import styles from './Queue.module.scss' 8 | 9 | const cx = classNames.bind(styles) 10 | 11 | const Queue: FC = () => { 12 | const { 13 | queue, 14 | currentTrack, 15 | currentTrackIndex, 16 | isShuffle, 17 | isPlaying, 18 | prevDocumentTitle, 19 | } = useContext(PlayerContext) 20 | 21 | useEffect(() => { 22 | if (isPlaying) { 23 | prevDocumentTitle.current = 'Spotify - Play Queue' 24 | } else { 25 | documentTitle('Spotify - Play Queue') 26 | } 27 | }, [isPlaying]) 28 | 29 | const queueNormalized = queue.filter((item) => item) 30 | 31 | return ( 32 |
33 | 34 |
35 | {queueNormalized.length !== 0 ? ( 36 | <> 37 |

Queue

38 |
39 |

Now playing

40 | 54 |
55 | {queue?.filter((item) => item)?.length > 1 && ( 56 |
57 |

Next

58 | track?.id !== currentTrack?.id) 63 | : queueNormalized.slice(currentTrackIndex + 1) 64 | } 65 | adjustOrder={1} 66 | /> 67 |
68 | )} 69 | 70 | ) : ( 71 |
72 |

No Queue Tracks

73 | 74 |
Home
75 | 76 | 81 | Help 82 | 83 |
84 | )} 85 |
86 |
87 |
88 | ) 89 | } 90 | 91 | export default Queue 92 | -------------------------------------------------------------------------------- /src/components/PlayingView/PlayingView.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useContext, useEffect } from 'react' 2 | import styles from './PlayingView.module.scss' 3 | import classNames from 'classnames/bind' 4 | import { PlayerContext } from '@/contexts/PlayerContext' 5 | import { AboutArtist } from '..' 6 | import { ArtistContext } from '@/contexts/ArtistContext' 7 | import { Image, SubTitle } from '../UIs' 8 | import Marquee from 'react-fast-marquee' 9 | import { CloseIcon, HeartIcon, MusicNote } from '@/assets/icons' 10 | import { Link } from 'react-router-dom' 11 | import { AppContext } from '@/App' 12 | import NextSong from './NextSong/NextSong' 13 | 14 | const cx = classNames.bind(styles) 15 | 16 | const PlayingView: FC = () => { 17 | const { currentTrack, queue, nextTrackIndex } = useContext(PlayerContext) 18 | const { setId, isLoading, profile, stats, aboutImg, avatarImg } = 19 | useContext(ArtistContext) 20 | const { setPlayingViewShowed } = useContext(AppContext) 21 | 22 | useEffect(() => { 23 | setId(currentTrack?.artists?.[0]?.id) 24 | }, [currentTrack]) 25 | 26 | return ( 27 |
28 |
29 |
30 |
31 | 34 |
35 |
36 |
37 |
38 | {currentTrack?.album?.images?.[0]?.url ? ( 39 | {currentTrack?.name} 43 | ) : ( 44 |
45 | 46 |
47 | )} 48 |
49 |
50 |
51 |
52 | 53 | 54 |

{currentTrack?.name}

55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 |
64 | 65 |
66 |
67 |
68 |
69 | 77 |
78 |
79 |
80 | 81 |
82 |
83 |
84 | ) 85 | } 86 | 87 | export default PlayingView 88 | -------------------------------------------------------------------------------- /src/pages/Episode/Episode.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../scss/variable'; 2 | @import '../../scss/mixin'; 3 | 4 | .episode-wrapper { 5 | height: 100%; 6 | position: relative; 7 | overflow: hidden; 8 | background-color: $bg-base; 9 | } 10 | 11 | .body { 12 | height: 100%; 13 | overflow: hidden; 14 | overflow-y: scroll; 15 | padding-bottom: 32px; 16 | padding-top: $navbar-height; 17 | position: relative; 18 | 19 | &::-webkit-scrollbar { 20 | display: none; 21 | } 22 | } 23 | 24 | .pivot-tracking { 25 | position: absolute; 26 | z-index: -999; 27 | } 28 | 29 | .main { 30 | position: relative; 31 | } 32 | 33 | .bg-blur { 34 | position: absolute; 35 | height: 232px; 36 | top: 0; 37 | left: 0; 38 | right: 0; 39 | background-image: linear-gradient(rgba(0, 0, 0, 0.6) 0, $bg-base 100%), $bg-noise; 40 | z-index: 0; 41 | } 42 | 43 | .action-bar { 44 | position: relative; 45 | display: flex; 46 | flex-direction: column; 47 | padding: 24px; 48 | gap: 16px; 49 | 50 | .top { 51 | display: flex; 52 | flex-direction: row; 53 | font-size: 14px; 54 | color: rgba($color: #fff, $alpha: 0.7); 55 | align-items: center; 56 | gap: 4px; 57 | letter-spacing: 1px; 58 | 59 | .dot { 60 | height: 3px; 61 | width: 3px; 62 | border-radius: 500px; 63 | background-color: rgba($color: #fff, $alpha: 0.7); 64 | } 65 | } 66 | 67 | .bottom { 68 | display: flex; 69 | flex-direction: row; 70 | align-items: center; 71 | 72 | .play-btn { 73 | margin-right: 32px; 74 | } 75 | 76 | .plus-btn { 77 | color: $text-subdued; 78 | cursor: pointer; 79 | transform-origin: center; 80 | 81 | &:hover { 82 | color: $white; 83 | transform: scale(1.04); 84 | } 85 | } 86 | } 87 | } 88 | 89 | .content { 90 | position: relative; 91 | padding-inline: 24px; 92 | max-width: 672px; 93 | 94 | .title { 95 | font-size: 24px; 96 | font-weight: 700; 97 | color: $white; 98 | } 99 | 100 | .desc { 101 | font-size: 16px; 102 | color: $text-neutral; 103 | 104 | a { 105 | color: $white; 106 | font-weight: 700; 107 | 108 | &:hover { 109 | text-decoration: underline; 110 | } 111 | } 112 | 113 | } 114 | } 115 | 116 | .expand-btn { 117 | margin-top: 4px; 118 | 119 | button { 120 | @include reset-button; 121 | background-color: transparent; 122 | font-size: 16px; 123 | color: $white; 124 | font-weight: 700; 125 | cursor: pointer; 126 | } 127 | } 128 | .expanded { 129 | @include text-line-clamp(5); 130 | } 131 | 132 | .see-all-btn { 133 | margin-top: 48px; 134 | padding-inline: 24px; 135 | 136 | button { 137 | @include reset-button; 138 | font-size: 14px; 139 | font-weight: 700; 140 | color: $white; 141 | background-color: transparent; 142 | padding: 7px 15px; 143 | border-radius: 500px; 144 | border: 1px solid $essential-subdued; 145 | cursor: pointer; 146 | transform-origin: center; 147 | 148 | &:hover { 149 | transform: scale(1.04); 150 | border-color: $white; 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/components/AudioPlayer/Left/Left.tsx: -------------------------------------------------------------------------------- 1 | import { HeartIcon, MusicNote } from '@/assets/icons' 2 | import { Image, SubTitle } from '@/components/UIs' 3 | import { PlayerContext } from '@/contexts/PlayerContext' 4 | import { useEllipsisHorizontal } from '@/hooks' 5 | import classNames from 'classnames/bind' 6 | import { FC, useContext, useMemo, useRef } from 'react' 7 | import Marquee from 'react-fast-marquee' 8 | import Skeleton from 'react-loading-skeleton' 9 | import { Link } from 'react-router-dom' 10 | import styles from './Left.module.scss' 11 | 12 | const cx = classNames.bind(styles) 13 | 14 | const Left: FC = () => { 15 | const { playBarData, playingType } = useContext(PlayerContext) 16 | const isLoading = useMemo( 17 | () => Boolean(!playBarData?.trackName), 18 | [playBarData?.trackName] 19 | ) 20 | 21 | const trackNameRef = useRef() 22 | const isEllipsisActive = useEllipsisHorizontal( 23 | trackNameRef.current, 24 | playBarData?.trackName 25 | ) 26 | 27 | return ( 28 |
29 |
30 | {playBarData?.thumb ? ( 31 | {playBarData?.trackName} 32 | ) : ( 33 |
34 | 35 |
36 | )} 37 |
38 |
39 |
40 |
41 | {playBarData?.trackName} 42 | {!isLoading ? ( 43 | isEllipsisActive ? ( 44 | 45 | 52 | {playBarData?.trackName} 53 | 54 | 55 | ) : ( 56 | 63 | {playBarData?.trackName} 64 | 65 | ) 66 | ) : ( 67 | 68 | )} 69 |
70 |
71 | {!isLoading ? ( 72 | 78 | ) : ( 79 | 80 | )} 81 |
82 |
83 |
84 |
{!isLoading && }
85 |
86 | ) 87 | } 88 | 89 | export default Left 90 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "master" ] 20 | schedule: 21 | - cron: '23 12 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 27 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 28 | permissions: 29 | actions: read 30 | contents: read 31 | security-events: write 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | language: [ 'javascript' ] 37 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] 38 | # Use only 'java' to analyze code written in Java, Kotlin or both 39 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@v3 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@v2 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | 55 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 56 | # queries: security-extended,security-and-quality 57 | 58 | 59 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 60 | # If this step fails, then you should remove it and run the build manually (see below) 61 | - name: Autobuild 62 | uses: github/codeql-action/autobuild@v2 63 | 64 | # ℹ️ Command-line programs to run using the OS shell. 65 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 66 | 67 | # If the Autobuild fails above, remove it and uncomment the following three lines. 68 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 69 | 70 | # - run: | 71 | # echo "Run, Build Application using script" 72 | # ./location_of_script_within_repo/buildscript.sh 73 | 74 | - name: Perform CodeQL Analysis 75 | uses: github/codeql-action/analyze@v2 76 | with: 77 | category: "/language:${{matrix.language}}" 78 | -------------------------------------------------------------------------------- /src/contexts/HomePageContext.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode, createContext, useEffect, useState } from 'react' 2 | import { fetchHomePageData } from '@/utils' 3 | import searchApi from '@/apis/searchApi' 4 | import { ResponseSectionItem, SectionProps } from '@/types/section' 5 | 6 | interface HomePageProviderProps { 7 | children: ReactNode 8 | } 9 | 10 | interface HomePageContext { 11 | featurePlaylist?: SectionProps 12 | newReleases?: SectionProps 13 | topMixes?: SectionProps 14 | suggestArtists?: SectionProps 15 | trending?: SectionProps 16 | mood?: SectionProps 17 | focus?: SectionProps 18 | jazz?: SectionProps 19 | chill?: SectionProps 20 | greetingAlbum?: ResponseSectionItem[] 21 | } 22 | 23 | export const HomePageContext = createContext({} as HomePageContext) 24 | 25 | export const HomePageProvider: FC = ({ children }) => { 26 | const [greetingAlbum, setGreetingAlbum] = useState([]) 27 | const [featurePlaylist, setFeaturePlaylist] = useState() 28 | const [newReleases, setNewRelease] = useState() 29 | const [topMixes, setTopMixes] = useState() 30 | const [suggestArtists, setSuggestArtists] = useState() 31 | const [trending, setTrending] = useState() 32 | const [mood, setMood] = useState() 33 | const [focus, setFocus] = useState() 34 | const [jazz, setJazz] = useState() 35 | const [chill, setChill] = useState() 36 | 37 | useEffect(() => { 38 | fetchHomePageData({ type: 'newRelease', setData: setNewRelease }) 39 | fetchHomePageData({ type: 'featuredPlaylists', setData: setFeaturePlaylist }) 40 | fetchHomePageData({ type: 'topMixes', setData: setTopMixes }) 41 | fetchHomePageData({ type: 'suggestedArtists', setData: setSuggestArtists }) 42 | fetchHomePageData({ 43 | type: 'category', 44 | categoryId: '0JQ5DAqbMKFQIL0AXnG5AK', 45 | categoryName: 'Trending', 46 | setData: setTrending, 47 | }) 48 | fetchHomePageData({ 49 | type: 'category', 50 | categoryId: '0JQ5DAqbMKFzHmL4tf05da', 51 | categoryName: 'Mood', 52 | setData: setMood, 53 | }) 54 | fetchHomePageData({ 55 | type: 'category', 56 | categoryId: '0JQ5DAqbMKFCbimwdOYlsl', 57 | categoryName: 'Focus', 58 | setData: setFocus, 59 | }) 60 | fetchHomePageData({ 61 | type: 'category', 62 | categoryId: '0JQ5DAqbMKFAJ5xb0fwo9m', 63 | categoryName: 'Jazz', 64 | setData: setJazz, 65 | }) 66 | fetchHomePageData({ 67 | type: 'category', 68 | categoryId: '0JQ5DAqbMKFFzDl7qN9Apr', 69 | categoryName: 'Chill', 70 | setData: setChill, 71 | }) 72 | }, []) 73 | 74 | useEffect(() => { 75 | const fetchData = async () => { 76 | const data = await searchApi({ 77 | query: 'album', 78 | types: ['album'], 79 | limit: 50, 80 | }) 81 | 82 | setGreetingAlbum(data?.albums.items) 83 | } 84 | 85 | fetchData() 86 | }, []) 87 | 88 | return ( 89 | 103 | {children} 104 | 105 | ) 106 | } 107 | -------------------------------------------------------------------------------- /src/utils/fetchHomePageData.ts: -------------------------------------------------------------------------------- 1 | import browserApi from '@/apis/browserApi' 2 | import { getCategoryPlaylist } from '@/apis/categoriesApi' 3 | import searchApi from '@/apis/searchApi' 4 | import { SectionProps } from '@/types/section' 5 | 6 | interface PropsType { 7 | type: 'newRelease' | 'featuredPlaylists' | 'topMixes' | 'suggestedArtists' | 'category' 8 | setData: React.Dispatch> 9 | limit?: number 10 | categoryId?: string 11 | categoryName?: string 12 | } 13 | 14 | const fetchHomePageData = (params: Partial) => { 15 | const { type, setData, limit = 50, categoryId, categoryName } = params 16 | 17 | if (type === 'category') { 18 | const fetchData = async () => { 19 | const data = await getCategoryPlaylist({ id: categoryId, limit: 20 }) 20 | const dataNormalized = data?.playlists?.items?.filter((item: any) => item) 21 | setData!({ 22 | title: categoryName, 23 | href: `/genre/${categoryId}`, 24 | apiType: 'spotify', 25 | data: dataNormalized, 26 | dataType: 'playlist', 27 | }) 28 | } 29 | fetchData() 30 | return 31 | } 32 | 33 | switch (type) { 34 | case 'newRelease': { 35 | const fetchData = async () => { 36 | const response = await browserApi({ 37 | limit: limit, 38 | country: 'VN', 39 | type: 'new-releases', 40 | }) 41 | setData!({ 42 | title: 'New Releases', 43 | href: '/section/newReleases', 44 | dataType: 'album', 45 | data: response, 46 | apiType: 'spotify', 47 | }) 48 | } 49 | fetchData() 50 | break 51 | } 52 | 53 | case 'featuredPlaylists': { 54 | const fetchData = async () => { 55 | const response = await browserApi({ 56 | limit: limit, 57 | country: 'VN', 58 | type: 'featured-playlists', 59 | }) 60 | setData!({ 61 | title: 'Featured Playlists', 62 | href: '/section/featurePlaylist', 63 | dataType: 'playlist', 64 | data: response, 65 | apiType: 'spotify', 66 | }) 67 | } 68 | fetchData() 69 | break 70 | } 71 | 72 | case 'topMixes': { 73 | const fetchData = async () => { 74 | const response = await searchApi({ 75 | query: 'chill mix lofi', 76 | types: ['playlist'], 77 | limit: limit, 78 | }) 79 | setData!({ 80 | title: 'Top mixes', 81 | href: '/section/topMixes', 82 | dataType: 'playlist', 83 | data: response?.playlists.items, 84 | apiType: 'spotify', 85 | }) 86 | } 87 | fetchData() 88 | break 89 | } 90 | 91 | case 'suggestedArtists': { 92 | const fetchData = async () => { 93 | const response = await searchApi({ 94 | query: 'artist', 95 | types: ['artist'], 96 | limit: limit, 97 | }) 98 | setData!({ 99 | title: 'Suggested artists', 100 | href: '/section/suggestedArtists', 101 | dataType: 'artist', 102 | data: response?.artists.items 103 | .sort((a: any, b: any) => -a.popularity + b.popularity) 104 | .filter((artist: any) => artist.images.length !== 0), 105 | apiType: 'spotify', 106 | }) 107 | } 108 | fetchData() 109 | break 110 | } 111 | } 112 | } 113 | 114 | export default fetchHomePageData 115 | --------------------------------------------------------------------------------