├── .gitignore ├── README.md ├── jsconfig.json ├── package.json ├── public ├── favicon-dark.png ├── favicon.png ├── icons │ ├── icon-128x128.png │ ├── icon-144x144.png │ ├── icon-152x152.png │ ├── icon-192x192.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── icon-72x72.png │ ├── icon-96x96.png │ └── ios-touch-icon.png ├── index.html ├── logo-cropped.png ├── logo.png ├── manifest.json ├── robots.txt └── splash │ ├── ipad_splash.png │ ├── ipadpro1_splash.png │ ├── ipadpro2_splash.png │ ├── ipadpro3_splash.png │ ├── iphone5_splash.png │ ├── iphone6_splash.png │ ├── iphoneplus_splash.png │ ├── iphonex_splash.png │ ├── iphonexr_splash.png │ └── iphonexsmax_splash.png ├── src ├── App.js ├── assets │ └── images │ │ ├── circles.svg │ │ ├── liked-songs-300.png │ │ ├── logo-error-icon.svg │ │ ├── logo-icon-inverse.svg │ │ ├── logo-icon.svg │ │ ├── logo-inverse.svg │ │ ├── logo-offline-icon.svg │ │ ├── logo.svg │ │ └── wave.svg ├── common │ ├── api │ │ └── getUserProfile.js │ ├── components │ │ ├── Avatar │ │ │ ├── Avatar.js │ │ │ ├── _avatar.scss │ │ │ └── index.js │ │ ├── Button │ │ │ ├── Button.js │ │ │ ├── _button.scss │ │ │ └── index.js │ │ ├── CheckBox │ │ │ ├── CheckBox.js │ │ │ ├── _checkbox.scss │ │ │ └── index.js │ │ ├── Dropdown │ │ │ ├── Dropdown.js │ │ │ └── index.js │ │ ├── Error │ │ │ ├── Error.js │ │ │ ├── _error.scss │ │ │ └── index.js │ │ ├── Icon │ │ │ ├── Icon.js │ │ │ └── index.js │ │ ├── Modal │ │ │ ├── Modal.js │ │ │ ├── _modal.scss │ │ │ └── index.js │ │ ├── Offline │ │ │ ├── Offline.js │ │ │ ├── _offline.scss │ │ │ └── index.js │ │ ├── OpenOnSpotifyButton │ │ │ ├── OpenOnSpotifyButton.js │ │ │ └── index.js │ │ ├── Text │ │ │ ├── Text.js │ │ │ ├── _text.scss │ │ │ └── index.js │ │ ├── Toggle │ │ │ ├── Toggle.js │ │ │ ├── _toggle.scss │ │ │ └── index.js │ │ ├── Tooltip │ │ │ ├── Tooltip.js │ │ │ └── index.js │ │ └── index.js │ ├── hooks │ │ └── useNumberFlow.js │ └── utils │ │ ├── notify.js │ │ ├── postEvent.js │ │ ├── store.js │ │ └── withApiErrorHandling.js ├── config.js ├── index.js ├── pages │ ├── Home │ │ ├── Footer.js │ │ ├── Home.js │ │ ├── _footer.scss │ │ ├── _home.scss │ │ └── index.js │ └── Main │ │ ├── api │ │ ├── constants.js │ │ ├── createRemixPlaylist.js │ │ ├── getPlaylistTracks.js │ │ ├── getPlaylists.js │ │ ├── searchRelatedTracks.js │ │ └── utils │ │ │ ├── getPaginatedItems.js │ │ │ └── getRemixSearchTerm.js │ │ ├── components │ │ ├── Accordion.js │ │ ├── Footer.js │ │ ├── Header.js │ │ ├── Main.js │ │ ├── Pagination.js │ │ ├── Playlist.js │ │ ├── Search.js │ │ └── Success.js │ │ ├── index.js │ │ └── styles │ │ ├── _accordion.scss │ │ ├── _header.scss │ │ ├── _main.scss │ │ ├── _pagination.scss │ │ ├── _playlist.scss │ │ ├── _search.scss │ │ └── _success.scss ├── pkce.js ├── service-worker.js ├── serviceWorkerRegistration.js └── styles │ ├── _animations.scss │ ├── _app.scss │ ├── _index.scss │ ├── _mixins.scss │ ├── _overrides.scss │ ├── _typography.scss │ ├── _vars.scss │ └── utils │ ├── _index.scss │ └── _margins.scss └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea/ 26 | .env 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ![React](https://img.shields.io/badge/react-v17+-60D9FA.svg) 4 | ![Build Status](https://therealsujitk-vercel-badge.vercel.app/?app=mixmello) 5 | ![Dependencies](https://img.shields.io/badge/dependencies-up%20to%20date-brightgreen.svg) 6 | [![GitHub Issues](https://img.shields.io/github/issues/alexgurr/mixmello.svg)](https://github.com/alexgurr/mixmello/issues) 7 | ![Contributions welcome](https://img.shields.io/badge/contributions-welcome-orange.svg) 8 | 9 | www.mixmello.com 10 | 11 |   12 | ## About 13 | Create remixed versions of your favourite Spotify playlists. 14 | 15 | 16 |   17 | 💡 Idea by [Divide-By-0](https://github.com/Divide-By-0/app-ideas-people-would-use). 18 | 19 |   20 | ### Stack 21 | Frontend: `react@latest` 22 | 23 | Design/Logo: [`@alexgurr`](https://twitter.com/alexgurr) 24 | 25 | Backend: `none` 26 | 27 | APIs: [`Spotify`](https://developer.spotify.com/documentation/web-api/) 28 | 29 | Authentication: [`OAuth pkce`](https://oauth.net/2/pkce/) 30 | 31 |   32 | ### Screenshots 33 | | | | 34 | |:-------------------------:|:-------------------------:| 35 | |screen shot 1 | screen shot 2| 36 | |screen shot 3 | screen shot 4| 37 | 38 | 39 |   40 | ## Getting Started 41 | 42 | ### Environment Variables 43 | #### Required 44 | - `REDIRECT_URL`: Redirect URL Spotify will redirect the OAuth flow back to. This should be added to the list of whitelisted domains in the Spotify console. Defaults to `localhost:3000` 45 | 46 | - `SPOTIFY_CLIENT_ID`: The Client ID of your Spotify app. Your client should have the scopes: `user-library-read` `playlist-modify-private` `playlist-read-private` `playlist-modify-public` `playlist-read-collaborative` 47 | 48 | - `SASS_PATH`: This should be set to **src/styles** or the SCSS import resolution will fail 49 | 50 | #### Optional 51 | - `GA_ID`: Google Analytics ID 52 | 53 | - `SENTRY_DSN`: Sentry error reporting DSN (url) 54 | 55 | 56 | 57 |   58 | ### Install 59 | `yarn` or `npm install` 60 | 61 |   62 | ### Start 63 | `yarn start` or `npm run start` 64 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src" 4 | }, 5 | "include": ["src"] 6 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mixmello", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@kunukn/react-collapse": "^2.2.9", 7 | "@n8tb1t/use-scroll-position": "^2.0.3", 8 | "@sentry/react": "^6.13.3", 9 | "@sentry/tracing": "^6.13.3", 10 | "@szhsin/react-menu": "^2.1.0", 11 | "@testing-library/jest-dom": "^5.11.4", 12 | "@testing-library/react": "^11.1.0", 13 | "@testing-library/user-event": "^12.1.10", 14 | "animate.css": "^4.1.1", 15 | "axios": "^0.22.0", 16 | "body-scroll-lock": "^4.0.0-beta.0", 17 | "bulma": "^0.9.3", 18 | "classnames": "^2.3.1", 19 | "crypto": "^1.0.1", 20 | "fuse.js": "^6.4.6", 21 | "lodash.memoize": "^4.1.2", 22 | "lodash.uniqby": "^4.7.0", 23 | "node-sass": "5", 24 | "noty": "^3.2.0-beta-deprecated", 25 | "query-string": "^7.0.1", 26 | "react": "^17.0.2", 27 | "react-dom": "^17.0.2", 28 | "react-helmet": "^6.1.0", 29 | "react-input-autosize": "^3.0.0", 30 | "react-responsive": "^9.0.0-beta.4", 31 | "react-router-dom": "^5.3.0", 32 | "react-scripts": "4.0.3", 33 | "react-select": "^5.0.0", 34 | "react-toggle": "^4.1.2", 35 | "react-tooltip": "^4.2.21", 36 | "rodal": "^1.8.1", 37 | "web-vitals": "^1.0.1", 38 | "webrix": "^1.5.6", 39 | "what-input": "^5.2.10", 40 | "workbox-core": "^6.3.0", 41 | "workbox-expiration": "^6.3.0", 42 | "workbox-precaching": "^6.3.0", 43 | "workbox-routing": "^6.3.0", 44 | "workbox-strategies": "^6.3.0" 45 | }, 46 | "scripts": { 47 | "start": "react-scripts start", 48 | "build": "react-scripts build", 49 | "test": "react-scripts test", 50 | "eject": "react-scripts eject" 51 | }, 52 | "eslintConfig": { 53 | "extends": [ 54 | "react-app", 55 | "react-app/jest" 56 | ] 57 | }, 58 | "browserslist": { 59 | "production": [ 60 | ">0.2%", 61 | "not dead", 62 | "not op_mini all" 63 | ], 64 | "development": [ 65 | "last 1 chrome version", 66 | "last 1 firefox version", 67 | "last 1 safari version" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /public/favicon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/favicon-dark.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/favicon.png -------------------------------------------------------------------------------- /public/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/icon-152x152.png -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/icon-96x96.png -------------------------------------------------------------------------------- /public/icons/ios-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/icons/ios-touch-icon.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 52 | 53 | mixmello 54 | 55 | 56 | 57 |
58 | 59 | 60 | -------------------------------------------------------------------------------- /public/logo-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/logo-cropped.png -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/logo.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "mixmello", 3 | "name": "mixmello", 4 | "description": "Create remixed versions of your favourite Spotify playlists.", 5 | "icons": [ 6 | { 7 | "src": "/icons/icon-72x72.png", 8 | "sizes": "72x72", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/icons/icon-96x96.png", 13 | "sizes": "96x96", 14 | "type": "image/png" 15 | }, 16 | { 17 | "src": "/icons/icon-128x128.png", 18 | "sizes": "128x128", 19 | "type": "image/png" 20 | }, 21 | { 22 | "src": "/icons/icon-144x144.png", 23 | "sizes": "144x144", 24 | "type": "image/png" 25 | }, 26 | { 27 | "src": "/icons/icon-152x152.png", 28 | "sizes": "152x152", 29 | "type": "image/png" 30 | }, 31 | { 32 | "src": "/icons/icon-192x192.png", 33 | "sizes": "192x192", 34 | "type": "image/png" 35 | }, 36 | { 37 | "src": "/icons/icon-384x384.png", 38 | "sizes": "384x384", 39 | "type": "image/png" 40 | }, 41 | { 42 | "src": "/icons/icon-512x512.png", 43 | "sizes": "512x512", 44 | "type": "image/png" 45 | } 46 | ], 47 | "start_url": ".", 48 | "display": "standalone", 49 | "background_color": "#fff" 50 | } 51 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/splash/ipad_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/ipad_splash.png -------------------------------------------------------------------------------- /public/splash/ipadpro1_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/ipadpro1_splash.png -------------------------------------------------------------------------------- /public/splash/ipadpro2_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/ipadpro2_splash.png -------------------------------------------------------------------------------- /public/splash/ipadpro3_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/ipadpro3_splash.png -------------------------------------------------------------------------------- /public/splash/iphone5_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/iphone5_splash.png -------------------------------------------------------------------------------- /public/splash/iphone6_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/iphone6_splash.png -------------------------------------------------------------------------------- /public/splash/iphoneplus_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/iphoneplus_splash.png -------------------------------------------------------------------------------- /public/splash/iphonex_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/iphonex_splash.png -------------------------------------------------------------------------------- /public/splash/iphonexr_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/iphonexr_splash.png -------------------------------------------------------------------------------- /public/splash/iphonexsmax_splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/public/splash/iphonexsmax_splash.png -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { useEffect, useState, Suspense, lazy } from 'react'; 3 | import queryString from 'querystring'; 4 | import { useHistory } from 'react-router-dom'; 5 | import { useBooleanState } from 'webrix/hooks'; 6 | import { generateCodeChallengeFromVerifier, generateCodeVerifier } from './pkce'; 7 | import getUserProfile from 'common/api/getUserProfile'; 8 | import { Avatar, Button, Error, Modal, Text } from 'common/components'; 9 | import postEvent from 'common/utils/postEvent'; 10 | import notify from 'common/utils/notify'; 11 | import store from './common/utils/store'; 12 | import CONFIG from './config'; 13 | import './styles/_app.scss'; 14 | 15 | const Home = lazy(() => import('pages/Home')); 16 | const Main = lazy(() => import('pages/Main')); 17 | 18 | async function getToken({ profile: profileProp, setToken, setProfile, authProps }) { 19 | const options = { 20 | method: 'POST', 21 | headers: { 'content-type': 'application/x-www-form-urlencoded' }, 22 | data: queryString.stringify({ 23 | ...authProps, 24 | client_id: CONFIG.SPOTIFY_CLIENT_ID 25 | }), 26 | url: 'https://accounts.spotify.com/api/token' 27 | }; 28 | 29 | const { data: { access_token: accessToken, refresh_token: refreshToken } } = await axios(options); 30 | const profile = profileProp || await getUserProfile({ token: accessToken }); 31 | 32 | setToken(accessToken); 33 | setProfile(profile); 34 | 35 | store.setItem('spotify-token', refreshToken); 36 | store.setItem('spotify-profile', JSON.stringify(profile)); 37 | } 38 | 39 | function App() { 40 | const storedProfile = store.getItem('spotify-profile'); 41 | const [error, setError] = useState(false); 42 | const [profile, setProfile] = useState(storedProfile ? JSON.parse(storedProfile) : null); 43 | const { value: confirmAuth, toggle: toggleConfirmAuth } = useBooleanState(); 44 | const [token, setToken] = useState(null); 45 | const { 46 | '?code': spotifyAuthCode, 47 | '?error': spotifyConnectError, 48 | state: spotifyState, 49 | } = queryString.parse(window.location.search); 50 | const history = useHistory(); 51 | 52 | useEffect(() => { 53 | if (!spotifyConnectError) { return; } 54 | 55 | if (spotifyConnectError === 'access_denied') { 56 | history.push('/'); 57 | 58 | return void notify({ 59 | text: 'It looks like you cancelled connecting to Spotify. Why don\'t you give it another try?', 60 | type: 'warning' 61 | }); 62 | } 63 | 64 | setError(true); 65 | }, [spotifyConnectError]); 66 | 67 | useEffect(() => { 68 | const refreshToken = store.getItem('spotify-token'); 69 | 70 | if (!refreshToken || spotifyAuthCode) { return; } 71 | 72 | const refresh = async () => { 73 | try { 74 | await getToken({ 75 | profile, 76 | setToken, 77 | setProfile, 78 | history, 79 | authProps: { 80 | refresh_token: refreshToken, 81 | grant_type: 'refresh_token', 82 | } 83 | }); 84 | } catch { 85 | toggleConfirmAuth(); 86 | } 87 | } 88 | 89 | refresh(); 90 | 91 | // Refresh token every 30m 92 | setInterval(refresh, 1800000); 93 | }, []); 94 | 95 | useEffect(() => { 96 | const handleConnect = async () => { 97 | if (!spotifyAuthCode) { return; } 98 | 99 | try { 100 | await getToken({ 101 | profile, 102 | setToken, 103 | setProfile, 104 | history, 105 | authProps: { 106 | grant_type: 'authorization_code', 107 | code: spotifyAuthCode, 108 | redirect_uri: CONFIG.REDIRECT_URL, 109 | code_verifier: spotifyState 110 | } 111 | }); 112 | 113 | if (!store.supported) { 114 | notify({ 115 | text: 'It looks like you\'ve turned off cookies. This means you\'ll have to reconnect to Spotify every time.', 116 | type: 'warning' 117 | }); 118 | } 119 | 120 | postEvent('connect-spotify'); 121 | } catch { 122 | setError(true); 123 | } finally { 124 | history.push('/'); 125 | } 126 | } 127 | 128 | handleConnect(); 129 | }, [spotifyAuthCode]); 130 | 131 | const signOut = () => { 132 | setProfile(null); 133 | setToken(null); 134 | 135 | if (confirmAuth) { 136 | toggleConfirmAuth(); 137 | } 138 | 139 | store.removeItem('spotify-token'); 140 | store.removeItem('spotify-profile'); 141 | } 142 | 143 | const onConnect = async () => { 144 | const codeVerifier = generateCodeVerifier(); 145 | const codeChallenge = await generateCodeChallengeFromVerifier(codeVerifier); 146 | 147 | window.location.href = 'https://accounts.spotify.com/authorize?response_type=code' 148 | + `&client_id=${CONFIG.SPOTIFY_CLIENT_ID}` 149 | + `&redirect_uri=${CONFIG.REDIRECT_URL}` 150 | + '&scope=user-library-read playlist-modify-private playlist-read-private playlist-modify-public playlist-read-collaborative' 151 | + `&state=${codeVerifier}` 152 | + `&code_challenge=${codeChallenge}` 153 | + '&code_challenge_method=S256'; 154 | }; 155 | 156 | if (error) { 157 | return ( 158 | 162 | ); 163 | } 164 | 165 | if (confirmAuth) { 166 | return ( 167 | 168 |
169 |
170 | 171 |
172 | {profile?.display_name} 173 | Not you? 174 |
175 | 183 |
184 | Keep seeing this? Get In Touch. 185 |
186 |
187 | ); 188 | } 189 | 190 | if ((spotifyAuthCode || store.getItem('spotify-token')) && !token) { return null; } 191 | 192 | if (!token) { 193 | return ( 194 | 195 | 196 | 197 | ); 198 | } 199 | 200 | return ( 201 | 202 |
203 | 204 | ); 205 | } 206 | 207 | export default App; 208 | -------------------------------------------------------------------------------- /src/assets/images/circles.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/assets/images/liked-songs-300.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexgurr/mixmello/d00bf864419e69e2e62129ff6e336cb8bfa27e9b/src/assets/images/liked-songs-300.png -------------------------------------------------------------------------------- /src/assets/images/logo-error-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/logo-icon-inverse.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/logo-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/logo-inverse.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/logo-offline-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/images/wave.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/common/api/getUserProfile.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | export default async ({ token }) => { 4 | if (!token) { return; } 5 | 6 | const { data } = await axios.get( 7 | `https://api.spotify.com/v1/me`, 8 | { headers: { Authorization: `Bearer ${token}`} } 9 | ); 10 | 11 | return data; 12 | } 13 | -------------------------------------------------------------------------------- /src/common/components/Avatar/Avatar.js: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import { forwardRef } from 'react'; 3 | import Icon from '../Icon'; 4 | import './_avatar.scss'; 5 | 6 | function Avatar({ className, url, large, onClick, onKeyDown }, ref) { 7 | return ( 8 |
14 | { 15 | url 16 | ? user-avatar 17 | : 18 | } 19 |
20 | ); 21 | } 22 | 23 | export default forwardRef(Avatar); 24 | -------------------------------------------------------------------------------- /src/common/components/Avatar/_avatar.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .avatar { 4 | background: $mid-grey; 5 | border-radius: 50%; 6 | height: 50px; 7 | width: 50px; 8 | position: relative; 9 | overflow: hidden; 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | 14 | > .icon { 15 | color: $dark-grey; 16 | } 17 | 18 | &--large { 19 | height: 75px; 20 | width: 75px; 21 | } 22 | 23 | > img { 24 | width: 100%; 25 | height: 100%; 26 | } 27 | } -------------------------------------------------------------------------------- /src/common/components/Avatar/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Avatar'; 2 | -------------------------------------------------------------------------------- /src/common/components/Button/Button.js: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import { forwardRef, useEffect, useState } from 'react'; 3 | import Icon from '../Icon'; 4 | import Tooltip from '../Tooltip'; 5 | import './_button.scss'; 6 | 7 | const DEFAULT_TYPE = 'primary'; 8 | const SUPPORTED_TYPES = [DEFAULT_TYPE, 'secondary', 'danger', 'icon-only']; 9 | const BUSY_ICON = 'fa-circle-notch'; 10 | 11 | const ICON_COLOUR_OVERRIDES = { 12 | [DEFAULT_TYPE]: 'white', 13 | danger: 'white' 14 | }; 15 | 16 | function Button({ 17 | type = DEFAULT_TYPE, 18 | fullWidth, 19 | children, 20 | disabled, 21 | onClick, 22 | icon, 23 | busy, 24 | tooltip, 25 | tooltipId, 26 | tooltipPlace = 'right', 27 | className, 28 | onStateEnd, 29 | state, 30 | id, 31 | dynamicWidth, 32 | iconSize, 33 | iconColour, 34 | brandIcon, 35 | small 36 | }, ref) { 37 | const supportedType = SUPPORTED_TYPES.includes(type); 38 | 39 | if (!supportedType) { 40 | console.warn('Button type not supported. Falling back to primary.'); 41 | } 42 | 43 | const [showStateIcon, setButtonState] = useState(Boolean(state)); 44 | const busyIcon = showStateIcon ? (state === 'success' ? 'checkmark-circle' : 'close') : BUSY_ICON; 45 | const prefixIcon = busy || showStateIcon ? busyIcon : icon; 46 | 47 | useEffect(() => { 48 | if (state) { 49 | setButtonState(true); 50 | 51 | const timeout = setTimeout(() => { 52 | setButtonState(false); 53 | 54 | if (!onStateEnd) { 55 | return; 56 | } 57 | 58 | onStateEnd(); 59 | }, 2000); 60 | 61 | return () => { 62 | if (!timeout) { 63 | return; 64 | } 65 | 66 | clearTimeout(timeout); 67 | }; 68 | } 69 | }, [state]); 70 | 71 | return ( 72 | 79 | 104 | 105 | ); 106 | } 107 | 108 | export default forwardRef(Button); 109 | -------------------------------------------------------------------------------- /src/common/components/Button/_button.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | @import 'mixins'; 3 | 4 | .button { 5 | background: $text; 6 | color: white; 7 | border: 0; 8 | height: 50px; 9 | min-height: 50px; 10 | border-radius: 4px; 11 | font-weight: 500; 12 | font-size: 16px; 13 | width: fit-content; 14 | cursor: pointer; 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | min-width: 150px; 19 | 20 | &--small { 21 | min-width: 100px; 22 | height: 30px; 23 | min-height: 30px; 24 | font-size: 12px; 25 | } 26 | 27 | &__content { 28 | display: flex; 29 | align-items: center; 30 | width: fit-content; 31 | overflow: hidden; 32 | padding-left: 20px; 33 | padding-right: 20px; 34 | position: relative; 35 | height: 100%; 36 | 37 | > span { 38 | color: white; 39 | white-space: nowrap; 40 | } 41 | 42 | > .icon { 43 | margin-right: 10px; 44 | } 45 | } 46 | 47 | &--dynamic-width &__content { 48 | padding-left: 20%; 49 | padding-right: 20%; 50 | } 51 | 52 | &:focus &__content { 53 | > span { 54 | color: white; 55 | } 56 | } 57 | 58 | &:hover { 59 | background: lighten($text, 2%); 60 | } 61 | 62 | &:hover &__content > span { 63 | color: white; 64 | } 65 | 66 | &:disabled { 67 | background-color: $text !important; 68 | pointer-events: none; 69 | cursor: default; 70 | opacity: 0.5; 71 | } 72 | 73 | &--full-width { 74 | width: 100% !important; 75 | } 76 | 77 | &--icon, 78 | &--icon-only { 79 | width: 50px; 80 | min-width: 50px; 81 | } 82 | 83 | &--icon &__content, 84 | &--icon-only &__content { 85 | padding: 0; 86 | width: 100%; 87 | 88 | > .icon { 89 | margin: auto; 90 | } 91 | } 92 | 93 | &--icon-only &__content > .icon { 94 | color: $text; 95 | } 96 | 97 | &--icon &__content > .icon { 98 | font-size: 20px; 99 | } 100 | 101 | &--icon-only:disabled { 102 | background: none !important; 103 | } 104 | 105 | &--busy &__content { 106 | > .icon { 107 | position: absolute; 108 | left: calc(50% - 15px); 109 | width: 30px; 110 | } 111 | } 112 | 113 | &--danger { 114 | background: $danger; 115 | 116 | &:hover { 117 | background: lighten($danger, 5%); 118 | } 119 | } 120 | 121 | &--secondary { 122 | background: white; 123 | border: 2px solid $text; 124 | 125 | &:focus { 126 | background: white; 127 | border-color: $primary; 128 | } 129 | 130 | &:hover { 131 | background: white; 132 | border-color: $primary; 133 | } 134 | 135 | &:disabled { 136 | background-color: white !important; 137 | } 138 | } 139 | 140 | &--secondary &__content > span, 141 | &--secondary &__content > .icon, { 142 | color: $text; 143 | } 144 | 145 | &--secondary:focus &__content > span, 146 | &--secondary:focus &__content > .icon, { 147 | color: $primary; 148 | } 149 | 150 | &--secondary:hover &__content > span, 151 | &--secondary:hover &__content > .icon, { 152 | color: $primary; 153 | } 154 | 155 | &--icon-only { 156 | @include scale-hover; 157 | 158 | background: none; 159 | border: 0; 160 | padding: 0; 161 | 162 | &:hover { 163 | background: none; 164 | } 165 | } 166 | } -------------------------------------------------------------------------------- /src/common/components/Button/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Button'; 2 | -------------------------------------------------------------------------------- /src/common/components/CheckBox/CheckBox.js: -------------------------------------------------------------------------------- 1 | import { useBooleanState } from 'webrix/hooks'; 2 | import cx from 'classnames'; 3 | import Text from '../Text'; 4 | import Icon from '../Icon'; 5 | import './_checkbox.scss'; 6 | 7 | export default function CheckBox({ checked: checkedProp, initialValue, onChange, label, className, disabled }) { 8 | const { value: localChecked, toggle: toggleChecked } = useBooleanState(initialValue); 9 | const checked = checkedProp !== void 0 ? checkedProp : localChecked; 10 | 11 | const handleChange = () => { 12 | const newChecked = !checked; 13 | 14 | onChange?.(newChecked); 15 | 16 | toggleChecked(); 17 | } 18 | 19 | return ( 20 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/common/components/CheckBox/_checkbox.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .checkbox { 4 | width: 24px; 5 | min-width: 24px; 6 | height: 24px; 7 | min-height: 24px; 8 | border-radius: 4px; 9 | border: 1px solid $mid-grey; 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | cursor: pointer; 14 | 15 | &:hover:not(&--checked) { 16 | border-color: $dark-grey; 17 | } 18 | 19 | &--checked { 20 | border-color: $text; 21 | background: $text; 22 | 23 | &:hover { 24 | border-color: lighten($text, 10%); 25 | background: lighten($text, 10%); 26 | } 27 | } 28 | 29 | &--disabled { 30 | opacity: 0.8; 31 | pointer-events: none; 32 | } 33 | 34 | &--checked &--disabled { 35 | opacity: 0.5; 36 | } 37 | 38 | > input { 39 | display: none; 40 | } 41 | } -------------------------------------------------------------------------------- /src/common/components/CheckBox/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './CheckBox'; 2 | -------------------------------------------------------------------------------- /src/common/components/Dropdown/Dropdown.js: -------------------------------------------------------------------------------- 1 | import Select from 'react-select'; 2 | import Async from 'react-select/async'; 3 | 4 | const getSelectStyles = ({ hasError, styles: { control, ...styles } = {} }) => ({ 5 | control: (base, state) => ({ 6 | ...base, 7 | borderColor: hasError ? '#FF453A' : (state.isFocused ? '#00BD85 !important' : '#CBCCCC'), 8 | boxShadow: hasError ? '0 0 0 1px #FF453A' : (state.isFocused ? '0 0 0 1px #00BD85' : void 0), 9 | height: '50px', 10 | color: 'red', 11 | ...(control?.(base, state) || {}) 12 | }), 13 | option: (base, { isSelected }) => ({ 14 | ...base, 15 | color: isSelected ? 'white' : '#264653', 16 | backgroundColor: isSelected ? '#00BD85' : void 0, 17 | ':active': { 18 | ...base[':active'], 19 | backgroundColor: 'red' 20 | }, 21 | ':hover': { 22 | ...base[':hover'], 23 | backgroundColor: isSelected ? '#00BD85' : '#E7E9EB', 24 | color: isSelected ? 'white' : '#264653' 25 | }, 26 | }), 27 | ...styles 28 | }); 29 | 30 | export default function Dropdown({ async, hasError, styles, ...props }) { 31 | const DropdownElement = async ? Async : Select; 32 | 33 | return ( 34 | 39 | ) 40 | } -------------------------------------------------------------------------------- /src/common/components/Dropdown/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Dropdown'; 2 | -------------------------------------------------------------------------------- /src/common/components/Error/Error.js: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | import { ReactComponent as ErrorLogoIcon } from 'assets/images/logo-error-icon.svg'; 3 | import { Button, Text } from '../'; 4 | import './_error.scss'; 5 | 6 | export default function Error({ title, tryAgainProps }) { 7 | return ( 8 |
9 | 10 | 11 | {title || 'Oh no! Something went wrong.'} 12 | It's probably a one time thing. 13 |
14 | 17 | {tryAgainProps && } 18 |
19 | 20 | Keep seeing this? Get In Touch. 21 | 22 |
23 | ); 24 | } -------------------------------------------------------------------------------- /src/common/components/Error/_error.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .error { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | margin: auto; 8 | padding-left: 40px; 9 | padding-right: 40px; 10 | background: white; 11 | height: 100vh; 12 | width: 100vw; 13 | justify-content: center; 14 | 15 | @media only screen and (max-width: $mobile-width) { 16 | padding-left: 20px; 17 | padding-right: 20px; 18 | } 19 | 20 | > svg { 21 | max-width: 175px; 22 | 23 | @media only screen and (max-width: $mobile-width) { 24 | width: 100px; 25 | } 26 | } 27 | 28 | > .text { 29 | max-width: 600px; 30 | text-align: center; 31 | } 32 | 33 | &__actions { 34 | display: flex; 35 | align-items: center; 36 | width: 100%; 37 | justify-content: center; 38 | 39 | > button { 40 | width: 200px; 41 | } 42 | 43 | @media only screen and (max-width: $mobile-width) { 44 | flex-direction: column; 45 | 46 | > button { 47 | width: 100%; 48 | max-width: 300px; 49 | } 50 | } 51 | 52 | > *:first-child:not(:last-child) { 53 | margin-right: 30px; 54 | 55 | @media only screen and (max-width: $mobile-width) { 56 | margin-right: 0; 57 | margin-bottom: 20px; 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/common/components/Error/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Error'; 2 | -------------------------------------------------------------------------------- /src/common/components/Icon/Icon.js: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | 3 | export default function Icon({ className, name, colour = '#112035', size = 24, brand, spin, onClick }) { 4 | return ( 5 | 10 | ); 11 | } -------------------------------------------------------------------------------- /src/common/components/Icon/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Icon'; 2 | -------------------------------------------------------------------------------- /src/common/components/Modal/Modal.js: -------------------------------------------------------------------------------- 1 | import Rodal from 'rodal'; 2 | import cx from 'classnames'; 3 | import { useEffect } from 'react'; 4 | import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'; 5 | import { Button, Text } from '../'; 6 | import './_modal.scss'; 7 | 8 | export default function Modal({ 9 | children, 10 | title, 11 | visible, 12 | onClose, 13 | className, 14 | width = 500, 15 | subtitle, 16 | stayOpen, 17 | hideCancel, 18 | saveProps, 19 | cancelProps = {} 20 | }) { 21 | useEffect(() => { 22 | if (!visible) { 23 | return void enableBodyScroll(document.body); 24 | } 25 | 26 | disableBodyScroll(document.body); 27 | }, [visible]); 28 | 29 | return ( 30 | 39 |
40 |
41 | {title} 42 | {subtitle ? {subtitle} : null} 43 |
44 | {!stayOpen ?
46 |
47 | {children} 48 |
49 | { 50 | (hideCancel || stayOpen) && !saveProps 51 | ? null 52 | : ( 53 |
54 | { 55 | !hideCancel ? ( 56 | 64 | ) 65 | : null 66 | } 67 | { 68 | saveProps ? ( 69 | 75 | ) 76 | : null 77 | } 78 |
79 | ) 80 | } 81 |
82 | ); 83 | } -------------------------------------------------------------------------------- /src/common/components/Modal/_modal.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .modal { 4 | &__content { 5 | overflow-y: auto; 6 | padding: 1px; 7 | } 8 | 9 | &__footer { 10 | margin-top: 40px; 11 | display: flex; 12 | align-items: center; 13 | 14 | > *:first-child { 15 | margin-left: auto; 16 | 17 | &:not(:last-child) { 18 | margin-right: 20px; 19 | } 20 | } 21 | } 22 | 23 | &__header { 24 | display: flex; 25 | align-items: flex-start; 26 | justify-content: space-between; 27 | margin-bottom: 40px; 28 | 29 | > .icon { 30 | font-size: 30px; 31 | cursor: pointer; 32 | margin-top: 9px; 33 | transition: transform 0.2s ease-in-out; 34 | 35 | &:hover { 36 | transform: scale(1.05); 37 | } 38 | 39 | @media only screen and (max-width: $mobile-width) { 40 | margin-top: 0; 41 | } 42 | } 43 | } 44 | } 45 | 46 | .rodal-dialog { 47 | border-radius: 12px !important; 48 | padding: 30px !important; 49 | display: flex; 50 | flex-direction: column; 51 | height: fit-content !important; 52 | max-height: calc(100vh - 80px); 53 | 54 | @media only screen and (max-width: $mobile-width) { 55 | width: calc(100vw - 20px) !important; 56 | padding: 30px 19px !important; 57 | max-height: calc(100vh - 20px); 58 | } 59 | } -------------------------------------------------------------------------------- /src/common/components/Modal/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Modal'; 2 | -------------------------------------------------------------------------------- /src/common/components/Offline/Offline.js: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import { useBooleanState, usePrevious } from 'webrix/hooks'; 3 | import { useEffect } from 'react'; 4 | import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock'; 5 | import { ReactComponent as OfflineLogo } from '../../../assets/images/logo-offline-icon.svg'; 6 | import Text from '../Text'; 7 | import './_offline.scss'; 8 | import { Helmet } from 'react-helmet'; 9 | 10 | export default function Offline({ children }) { 11 | const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine); 12 | const previousOnline = usePrevious(online); 13 | 14 | useEffect(() => { 15 | if (!online) { return void disableBodyScroll(document.body); } 16 | 17 | enableBodyScroll(document.body); 18 | }, [online]); 19 | 20 | useEffect(() => { 21 | window.addEventListener('online', setOnline); 22 | window.addEventListener('offline', setOffline); 23 | 24 | return () => { 25 | window.removeEventListener('online', setOnline); 26 | window.removeEventListener('offline', setOffline); 27 | }; 28 | }, []); 29 | 30 | return ( 31 | <> 32 |
41 | {!online && } 42 |
43 | 44 |
45 | You're not online 46 | Check your internet connection. 47 |
48 |
49 |
50 |
51 | {children} 52 | 53 | ) 54 | } -------------------------------------------------------------------------------- /src/common/components/Offline/_offline.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .offline { 4 | position: fixed; 5 | top: 0; 6 | z-index: 4; 7 | left: calc(50% - 200px); 8 | width: 400px; 9 | padding-top: 40px; 10 | 11 | @media only screen and (max-width: $mobile-width) { 12 | padding-top: 20px; 13 | } 14 | 15 | @media only screen and (max-width: 500px) { 16 | padding-top: 20px; 17 | width: calc(100% - 40px); 18 | left: 20px; 19 | } 20 | 21 | &__content { 22 | padding: 15px 20px; 23 | background: white; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | border-radius: 6px; 28 | 29 | > svg { 30 | height: 50px; 31 | width: auto; 32 | margin-right: 20px; 33 | } 34 | } 35 | 36 | &__overlay { 37 | position: fixed; 38 | z-index: 3; 39 | background: rgba(0, 0, 0, 0.8); 40 | top: 0; 41 | left: 0; 42 | width: 100vw; 43 | height: 100vh; 44 | opacity: 0; 45 | transition: opacity 0.5s ease-in-out; 46 | pointer-events: none; 47 | 48 | &--visible { 49 | opacity: 1; 50 | pointer-events: unset; 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/common/components/Offline/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Offline'; 2 | -------------------------------------------------------------------------------- /src/common/components/OpenOnSpotifyButton/OpenOnSpotifyButton.js: -------------------------------------------------------------------------------- 1 | import { Menu, MenuItem } from '@szhsin/react-menu'; 2 | import { Button, Icon } from '../index'; 3 | 4 | export default function OpenOnSpotifyButton({ buttonType, type = 'playlist', id, small, action = 'Open' }) { 5 | const onOpenUrl = () => { 6 | window.open(`https://open.spotify.com/${type}/${id}`); 7 | }; 8 | 9 | const onOpenApp = () => { 10 | window.open(`spotify:${type}:${id}`); 11 | }; 12 | 13 | return ( 14 | 19 | {action} On Spotify 20 | 21 | )} 22 | transition 23 | > 24 | 25 | 26 | In The App 27 | 28 | 29 | 30 | On The Web 31 | 32 | 33 | ); 34 | } -------------------------------------------------------------------------------- /src/common/components/OpenOnSpotifyButton/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './OpenOnSpotifyButton'; 2 | -------------------------------------------------------------------------------- /src/common/components/Text/Text.js: -------------------------------------------------------------------------------- 1 | import cx from 'classnames'; 2 | import './_text.scss'; 3 | 4 | const styles = ({ centered, bold, ellipsis }) => ({ 5 | textAlign: centered ? 'center' : void 0, 6 | fontWeight: bold ? '800' : void 0, 7 | textOverflow: ellipsis ? 'ellipsis' : void 0, 8 | overflow: ellipsis ? 'hidden' : void 0, 9 | whiteSpace: ellipsis ? 'nowrap' : void 0 10 | }); 11 | 12 | export default function Text({ 13 | heading, 14 | subHeading, 15 | children, 16 | bold, 17 | className, 18 | centered, 19 | ellipsis, 20 | danger, 21 | small, 22 | inverse 23 | }) { 24 | const computedClassName = cx( 25 | 'text', 26 | { 27 | 'text--danger': danger, 28 | 'text--small': small, 29 | 'text--inverse': inverse 30 | }, 31 | className 32 | ); 33 | 34 | if (heading) { 35 | return

{children}

; 36 | } 37 | 38 | if (subHeading) { 39 | return

{children}

; 40 | } 41 | 42 | return

{children}

; 43 | } 44 | -------------------------------------------------------------------------------- /src/common/components/Text/_text.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | @mixin text { 4 | color: $text; 5 | font-weight: 400; 6 | } 7 | 8 | @mixin header { 9 | font-weight: 800; 10 | } 11 | 12 | h1.text { 13 | @include header; 14 | 15 | font-size: 30px; 16 | 17 | @media only screen and (max-width: $mobile-width) { 18 | font-size: 20px; 19 | } 20 | } 21 | 22 | h2.text { 23 | @include header; 24 | 25 | font-size: 24px; 26 | 27 | @media only screen and (max-width: $mobile-width) { 28 | font-size: 18px; 29 | } 30 | } 31 | 32 | p.text { 33 | font-size: 15px; 34 | } 35 | 36 | .text { 37 | @include text; 38 | 39 | &--danger { 40 | color: $danger; 41 | } 42 | 43 | &--inverse { 44 | color: white; 45 | 46 | > strong { 47 | color: white; 48 | } 49 | } 50 | 51 | &--bold { 52 | font-weight: 800; 53 | } 54 | 55 | &--small { 56 | font-size: 12px !important; 57 | } 58 | } -------------------------------------------------------------------------------- /src/common/components/Text/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Text'; 2 | -------------------------------------------------------------------------------- /src/common/components/Toggle/Toggle.js: -------------------------------------------------------------------------------- 1 | import RToggle from 'react-toggle'; 2 | import { useRef } from 'react'; 3 | import cx from 'classnames'; 4 | import Text from '../Text'; 5 | import './_toggle.scss'; 6 | 7 | export default function Toggle({ className, label = null, defaultChecked, onChange }) { 8 | const toggleRef = useRef(null); 9 | 10 | const onChangeHandler = () => { 11 | if (!toggleRef) { return; } 12 | 13 | onChange?.(toggleRef.current.input?.checked); 14 | }; 15 | 16 | return ( 17 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/common/components/Toggle/_toggle.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .toggle { 4 | display: flex; 5 | align-items: center; 6 | 7 | .react-toggle:not(.react-toggle--checked) { 8 | .react-toggle-track { 9 | background: #E5E5E5; 10 | } 11 | 12 | .react-toggle-thumb { 13 | border-color: #E5E5E5; 14 | } 15 | 16 | &:hover { 17 | .react-toggle-track { 18 | background: darken(#E5E5E5, 5%); 19 | } 20 | 21 | .react-toggle-thumb { 22 | border-color: darken(#E5E5E5, 5%); 23 | } 24 | } 25 | } 26 | 27 | .react-toggle--focus, 28 | .react-toggle:active { 29 | .react-toggle-thumb { 30 | box-shadow: unset !important;// 0 0 2px 3px $primary; 31 | } 32 | } 33 | 34 | .react-toggle--checked { 35 | .react-toggle-track { 36 | background-color: $text; 37 | } 38 | 39 | .react-toggle-thumb { 40 | border-color: $text; 41 | } 42 | 43 | &:hover { 44 | .react-toggle-track { 45 | background-color: lighten($text, 7%) !important; 46 | } 47 | 48 | .react-toggle-thumb { 49 | border-color: lighten($text, 7%) !important; 50 | } 51 | } 52 | } 53 | 54 | &__label { 55 | margin-left: 20px; 56 | display: flex; 57 | align-items: center; 58 | font-size: 15px; 59 | flex: 1; 60 | } 61 | } -------------------------------------------------------------------------------- /src/common/components/Toggle/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Toggle'; 2 | -------------------------------------------------------------------------------- /src/common/components/Tooltip/Tooltip.js: -------------------------------------------------------------------------------- 1 | import ReactTooltip from 'react-tooltip'; 2 | 3 | export default function Tooltip({ id, text, place = 'top', delayShow, children, hide }) { 4 | if (hide || !id) { return children; } 5 | 6 | return ( 7 | <> 8 |
9 | {children} 10 |
11 | 22 | {text} 23 | 24 | 25 | ) 26 | } -------------------------------------------------------------------------------- /src/common/components/Tooltip/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Tooltip'; 2 | -------------------------------------------------------------------------------- /src/common/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Button } from './Button'; 2 | export { default as Text } from './Text'; 3 | export { default as Toggle } from './Toggle'; 4 | export { default as Dropdown } from './Dropdown'; 5 | export { default as Icon } from './Icon'; 6 | export { default as Tooltip } from './Tooltip'; 7 | export { default as Modal } from './Modal'; 8 | export { default as Avatar } from './Avatar'; 9 | export { default as Error } from './Error'; 10 | export { default as Offline } from './Offline'; 11 | export { default as OpenOnSpotifyButton } from './OpenOnSpotifyButton'; 12 | -------------------------------------------------------------------------------- /src/common/hooks/useNumberFlow.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export default function useNumberFlow({ initialNumber = 0, max, min = 0, increment = 1 } = {}) { 4 | const [currentNumber, setCurrentNumber] = useState(initialNumber || min || 0); 5 | const atCeiling = max && (currentNumber + increment > max); 6 | const atFloor = currentNumber - increment < min; 7 | 8 | const onNext = () => { 9 | if (atCeiling) { return; } 10 | 11 | setCurrentNumber(currentNumber + increment); 12 | }; 13 | 14 | const onPrevious = () => { 15 | if (atFloor) { return; } 16 | 17 | setCurrentNumber(currentNumber - increment); 18 | }; 19 | 20 | return { 21 | next: onNext, 22 | previous: onPrevious, 23 | number: currentNumber, 24 | atCeiling, 25 | atFloor, 26 | setNumber: setCurrentNumber 27 | } 28 | } -------------------------------------------------------------------------------- /src/common/utils/notify.js: -------------------------------------------------------------------------------- 1 | import Noty from 'noty'; 2 | 3 | export default function notify({ type = 'error', text }) { 4 | new Noty({ 5 | theme: 'metroui', 6 | text, 7 | type: type, 8 | timeout: 3000, 9 | layout: 'bottomRight', 10 | progressBar: false 11 | }).show(); 12 | } 13 | -------------------------------------------------------------------------------- /src/common/utils/postEvent.js: -------------------------------------------------------------------------------- 1 | export default function postEvent(event) { 2 | // eslint-disable-next-line no-undef 3 | if (!gtag) { return; } 4 | 5 | try { 6 | // eslint-disable-next-line no-undef 7 | gtag('event', event, { value: 1 }); 8 | } catch {} 9 | } -------------------------------------------------------------------------------- /src/common/utils/store.js: -------------------------------------------------------------------------------- 1 | const supported = (() => { 2 | try { 3 | localStorage.getItem('compatibility-check'); 4 | 5 | return true; 6 | } catch { 7 | return false; 8 | } 9 | })(); 10 | 11 | function setItem(...args) { 12 | if (!supported) { return; } 13 | 14 | try { 15 | localStorage.setItem(...args); 16 | } catch {} 17 | } 18 | 19 | function getItem(key) { 20 | if (!supported) { return; } 21 | 22 | try { 23 | return localStorage.getItem(key); 24 | } catch { 25 | return null; 26 | } 27 | } 28 | 29 | function removeItem(key) { 30 | if (!supported) { return; } 31 | 32 | try { 33 | localStorage.removeItem(key); 34 | } catch {} 35 | } 36 | 37 | export default { 38 | setItem, 39 | getItem, 40 | removeItem, 41 | supported 42 | }; 43 | -------------------------------------------------------------------------------- /src/common/utils/withApiErrorHandling.js: -------------------------------------------------------------------------------- 1 | import notify from './notify'; 2 | 3 | export default function withApiErrorHandling(fn) { 4 | return async (...args) => { 5 | try { 6 | const res = await fn(...args); 7 | 8 | return res; 9 | } catch { 10 | notify({ text: 'Hmm, something went wrong. Why not give it another try?' }); 11 | 12 | throw new Error(); 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | const CONFIG = { 2 | REDIRECT_URL: process.env.REACT_APP_REDIRECT_URL || 'http://localhost:3000', 3 | SPOTIFY_CLIENT_ID: process.env.REACT_APP_SPOTIFY_CLIENT_ID, 4 | GA_ID: process.env.REACT_APP_GA_ID, 5 | SENTRY_DSN: process.env.REACT_APP_SENTRY_DSN 6 | }; 7 | 8 | export default CONFIG; 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { init as initSentry, ErrorBoundary } from "@sentry/react"; 5 | import { Integrations as TracingIntegrations } from "@sentry/tracing"; 6 | import { register as registerServiceWorker } from './serviceWorkerRegistration'; 7 | import { Error, Offline } from 'common/components'; 8 | import App from './App'; 9 | import CONFIG from './config'; 10 | import 'what-input'; 11 | import './styles/_index.scss'; 12 | 13 | if (process.env.NODE_ENV !== 'development') { 14 | initSentry({ 15 | dsn: CONFIG.SENTRY_DSN, 16 | integrations: [new TracingIntegrations.BrowserTracing()], 17 | tracesSampleRate: 0.25 18 | }); 19 | } 20 | 21 | ReactDOM.render( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | , 31 | document.getElementById('root') 32 | ); 33 | 34 | registerServiceWorker(); -------------------------------------------------------------------------------- /src/pages/Home/Footer.js: -------------------------------------------------------------------------------- 1 | import { Icon, Text } from 'common/components'; 2 | import { ReactComponent as LogoIcon } from 'assets/images/logo-icon-inverse.svg'; 3 | import './_footer.scss'; 4 | 5 | export default function Footer() { 6 | return ( 7 |
8 | 9 |
10 | 30 | 31 | Idea by Divide-By-0 32 | 33 | 34 | ©2021 Alex Gurr. All rights reserved. Various trademarks held by their respective owners. 35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/Home/Home.js: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet'; 2 | import { Button, Text } from 'common/components'; 3 | import { ReactComponent as Logo } from '../../assets/images/logo-inverse.svg'; 4 | import { ReactComponent as Waves } from '../../assets/images/wave.svg'; 5 | import { ReactComponent as Circles } from '../../assets/images/circles.svg'; 6 | import Footer from './Footer'; 7 | import './_home.scss'; 8 | 9 | export default function Home({ onConnect }) { 10 | return ( 11 |
12 | 13 |
14 | 15 | Create remixed versions of your favourite playlists. 16 | Search. Remix. Groove to the beat. 17 | 23 | 24 |
25 |
26 |
27 | How It Works 28 | Connect your Spotify account and get ready to dance to some remixes. 29 |
30 |
31 |
32 |
1
33 | Choose a playlist 34 |
35 | 36 | Search for a playlist across your private, public and liked songs playlists. 37 | 38 |
39 |
40 |
41 |
2
42 | Browse your remixes 43 |
44 | We’ll look for remixed versions of your songs, where you can preview them and choose what to keep. 45 |
46 |
47 |
48 |
3
49 | Save to Spotify 50 |
51 | Choose a name for your new playlist and save it directly to your Spotify account. 52 |
53 |
54 |
55 |
56 |
57 | 58 |
59 | ); 60 | } -------------------------------------------------------------------------------- /src/pages/Home/_footer.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .footer { 4 | width: 100%; 5 | display: flex; 6 | flex-direction: column; 7 | padding: 0 40px 250px; 8 | 9 | @media only screen and (max-width: $mobile-width) { 10 | padding: 40px 00px 100px; 11 | } 12 | 13 | &__copyright.text { 14 | font-size: 12px !important; 15 | 16 | @media only screen and (max-width: $mobile-width) { 17 | text-align: center; 18 | } 19 | } 20 | 21 | > svg { 22 | width: auto; 23 | height: 100px; 24 | margin-left: auto; 25 | margin-right: auto; 26 | margin-bottom: 100px; 27 | 28 | @media only screen and (max-width: $mobile-width) { 29 | width: 50px; 30 | height: auto; 31 | margin-bottom: 60px; 32 | } 33 | } 34 | 35 | &__content { 36 | width: 100%; 37 | max-width: 1000px; 38 | margin-left: auto; 39 | margin-right: auto; 40 | 41 | a { 42 | color: white !important; 43 | 44 | &:hover { 45 | color: $text !important; 46 | } 47 | 48 | > .icon { 49 | &:hover { 50 | color: $text !important; 51 | } 52 | 53 | @media only screen and (max-width: $mobile-width) { 54 | width: auto; 55 | height: 25px; 56 | } 57 | } 58 | } 59 | 60 | &__links { 61 | display: flex; 62 | justify-content: space-between; 63 | align-items: center; 64 | } 65 | 66 | &__list { 67 | display: flex; 68 | align-items: center; 69 | 70 | &__accessible { 71 | display: none; 72 | } 73 | 74 | > *:not(:last-child) { 75 | margin-right: 20px; 76 | 77 | @media only screen and (max-width: $mobile-width) { 78 | margin-right: 10px; 79 | } 80 | } 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /src/pages/Home/_home.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .home { 4 | background: $primary; 5 | flex: 1; 6 | padding-left: 20px; 7 | padding-right: 20px; 8 | position: relative; 9 | 10 | @media only screen and (max-width: $mobile-width) { 11 | padding-left: 40px; 12 | padding-right: 40px; 13 | } 14 | 15 | &__intro { 16 | height: 100vh; 17 | display: flex; 18 | flex-direction: column; 19 | align-items: flex-start; 20 | justify-content: center; 21 | padding-left: 60px; 22 | padding-right: 60px; 23 | 24 | @media only screen and (max-width: $mobile-width) { 25 | padding-left: 0; 26 | padding-right: 0; 27 | align-items: center; 28 | text-align: center; 29 | 30 | > h2.text { 31 | margin-top: 30px; 32 | } 33 | } 34 | 35 | > svg:not(:first-child) { 36 | height: 100vh; 37 | min-height: 750px; 38 | 39 | @media only screen and (max-width: 1050px) { 40 | .circles { 41 | &__top-left { 42 | cy: 0; 43 | } 44 | 45 | &__top-right { 46 | cx: 950; 47 | } 48 | 49 | &__bottom-left { 50 | cy: 600 51 | } 52 | } 53 | } 54 | 55 | @media only screen and (max-height: 700px) { 56 | .circles { 57 | &__top-left { 58 | cy: -150; 59 | } 60 | } 61 | } 62 | 63 | @media only screen and (max-height: 600px) { 64 | .circles { 65 | &__top-left, 66 | &__bottom-left { 67 | display: none; 68 | } 69 | } 70 | } 71 | 72 | @media only screen and (max-width: 700px) { 73 | display: none; 74 | } 75 | } 76 | 77 | &__connect { 78 | display: flex; 79 | align-items: center; 80 | 81 | > a { 82 | margin-left: 20px; 83 | cursor: pointer; 84 | } 85 | 86 | @media only screen and (max-width: $mobile-width) { 87 | flex-direction: column; 88 | justify-content: center; 89 | width: 100%; 90 | 91 | > .button { 92 | width: 100%; 93 | margin-bottom: 10px; 94 | max-width: 400px; 95 | } 96 | 97 | > a { 98 | margin-left: 0; 99 | margin-top: 10px; 100 | } 101 | } 102 | } 103 | 104 | &__logo { 105 | height: 75px; 106 | width: auto; 107 | margin-bottom: 20px; 108 | 109 | @media only screen and (max-width: $mobile-width) { 110 | width: 100%; 111 | height: auto; 112 | max-width: 400px; 113 | } 114 | } 115 | 116 | > h1.text { 117 | font-size: 70px; 118 | margin-bottom: 0; 119 | max-width: 850px; 120 | 121 | @media only screen and (max-height: 1000px) and (min-width: 1101px){ 122 | font-size: 50px; 123 | } 124 | 125 | @media only screen and (max-width: 1100px) { 126 | font-size: 40px; 127 | max-width: 700px; 128 | } 129 | 130 | 131 | @media only screen and (max-width: $mobile-width) { 132 | font-size: 30px; 133 | max-width: 500px; 134 | } 135 | } 136 | 137 | &__circles { 138 | width: 100vw; 139 | position: absolute; 140 | top: 0; 141 | left: 0; 142 | pointer-events: none; 143 | } 144 | } 145 | 146 | &__getting-started { 147 | display: flex; 148 | flex-direction: column; 149 | width: 100%; 150 | padding-top: 100px; 151 | padding-bottom: 100px; 152 | 153 | &__title { 154 | margin-bottom: 10px; 155 | } 156 | 157 | &__subtitle { 158 | font-size: 18px !important; 159 | margin-bottom: 100px; 160 | max-width: 600px; 161 | //opacity: 0.6; 162 | font-weight: 400; 163 | 164 | @media only screen and (max-width: $mobile-width) { 165 | margin-bottom: 60px; 166 | font-size: 15px !important; 167 | } 168 | } 169 | 170 | @media only screen and (max-width: $mobile-width) { 171 | padding-top: 40px; 172 | padding-bottom: 40px; 173 | } 174 | 175 | &__block { 176 | max-width: 350px; 177 | text-align: center; 178 | flex: 1; 179 | 180 | &:not(:last-of-type) { 181 | margin-right: 20px; 182 | } 183 | 184 | @media only screen and (max-width: 800px) { 185 | margin-left: auto; 186 | margin-right: auto; 187 | } 188 | 189 | > .text { 190 | font-size: 18px !important; 191 | 192 | @media 193 | only screen and (max-width: 1050px) and (min-width: 800px), 194 | only screen and (max-width: $mobile-width) { 195 | font-size: 15px !important; 196 | margin-top: 0; 197 | } 198 | } 199 | 200 | &__header { 201 | display: flex; 202 | align-items: center; 203 | width: fit-content; 204 | margin-left: auto; 205 | margin-right: auto; 206 | margin-bottom: 20px; 207 | font-size: 30px; 208 | 209 | @media 210 | only screen and (max-width: 1050px) and (min-width: 800px), 211 | only screen and (max-width: $mobile-width) { 212 | > .text { 213 | font-size: 18px; 214 | } 215 | } 216 | 217 | @media 218 | only screen and (max-width: 950px) and (min-width: 800px), 219 | only screen and (max-width: $mobile-width) { 220 | margin-bottom: 10px; 221 | } 222 | } 223 | 224 | &__number { 225 | background: white; 226 | font-weight: 800; 227 | border-radius: 50%; 228 | height: 50px; 229 | width: 50px; 230 | min-width: 50px; 231 | font-size: 30px; 232 | display: flex; 233 | align-items: center; 234 | justify-content: center; 235 | margin-right: 15px; 236 | color: $text; 237 | 238 | @media only screen and (max-width: 1050px) { 239 | height: 40px; 240 | width: 40px; 241 | min-height: 40px; 242 | min-width: 40px; 243 | font-size: 25px; 244 | } 245 | 246 | @media 247 | only screen and (max-width: 950px) and (min-width: 800px), 248 | only screen and (max-width: $mobile-width) { 249 | height: 30px; 250 | width: 30px; 251 | min-height: 30px; 252 | min-width: 30px; 253 | font-size: 20px; 254 | } 255 | } 256 | } 257 | 258 | &__container { 259 | display: flex; 260 | justify-content: space-evenly; 261 | max-width: 1500px; 262 | 263 | @media only screen and (max-width: 800px) { 264 | flex-direction: column; 265 | 266 | > *:not(:last-child) { 267 | margin-bottom: 60px; 268 | } 269 | } 270 | } 271 | } 272 | 273 | &__learn-more { 274 | &__waves { 275 | width: 100vw; 276 | height: auto; 277 | position: absolute; 278 | bottom: 0; 279 | left: 0; 280 | 281 | > path { 282 | fill: $text; 283 | } 284 | } 285 | } 286 | } -------------------------------------------------------------------------------- /src/pages/Home/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Home'; 2 | -------------------------------------------------------------------------------- /src/pages/Main/api/constants.js: -------------------------------------------------------------------------------- 1 | export const LIKED_PLAYLIST_ID = 'liked'; 2 | -------------------------------------------------------------------------------- /src/pages/Main/api/createRemixPlaylist.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import withApiErrorHandling from 'common/utils/withApiErrorHandling'; 3 | 4 | export default withApiErrorHandling(async ({ token, remixedTracks, playlistName, profileId, publicPlaylist }) => { 5 | if (!token) { return; } 6 | 7 | const { data: { id } } = await axios.post( 8 | `https://api.spotify.com/v1/users/${profileId}/playlists`, 9 | { 10 | name: playlistName, 11 | public: publicPlaylist 12 | }, 13 | { headers: { Authorization: `Bearer ${token}`} } 14 | ); 15 | 16 | try { 17 | await axios.post( 18 | `https://api.spotify.com/v1/playlists/${id}/tracks?uris=${remixedTracks.join(',')}`, 19 | {}, 20 | { headers: { Authorization: `Bearer ${token}`} } 21 | ); 22 | 23 | return id; 24 | } catch { 25 | await axios.delete( 26 | `https://api.spotify.com/v1/playlists/${id}/followers`, 27 | { headers: { Authorization: `Bearer ${token}`} } 28 | ); 29 | 30 | throw new Error(); 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /src/pages/Main/api/getPlaylistTracks.js: -------------------------------------------------------------------------------- 1 | import getPaginatedItems from './utils/getPaginatedItems'; 2 | import withApiErrorHandling from 'common/utils/withApiErrorHandling'; 3 | import { LIKED_PLAYLIST_ID } from './constants'; 4 | 5 | export default withApiErrorHandling(async ({ token, playlistId }) => { 6 | if (!token) { return; } 7 | 8 | const items = await getPaginatedItems( 9 | token, 10 | playlistId === LIKED_PLAYLIST_ID ? 'me/tracks' : `playlists/${playlistId}/tracks` 11 | ); 12 | 13 | return items.map(item => item.track).filter(Boolean); 14 | }); 15 | -------------------------------------------------------------------------------- /src/pages/Main/api/getPlaylists.js: -------------------------------------------------------------------------------- 1 | import _memoize from 'lodash.memoize'; 2 | import Fuse from 'fuse.js'; 3 | import axios from 'axios'; 4 | import getPaginatedItems from './utils/getPaginatedItems'; 5 | import withApiErrorHandling from 'common/utils/withApiErrorHandling'; 6 | import likedSongsImage from '../../../assets/images/liked-songs-300.png'; 7 | import { LIKED_PLAYLIST_ID } from './constants'; 8 | 9 | function filterPlaylists(playlists, filter) { 10 | const fuse = new Fuse(playlists, { shouldSort: true, keys: ['name'], threshold: 0.3 }) 11 | const results = fuse.search(filter); 12 | 13 | return results.slice(0, 5).map(({ item }) => item); 14 | } 15 | 16 | async function getLikedSong(token) { 17 | const { data: { items } } = await axios.get( 18 | `https://api.spotify.com/v1/me/tracks?limit=1`, 19 | { headers: { Authorization: `Bearer ${token}`} } 20 | ); 21 | 22 | return items; 23 | } 24 | 25 | const memoGetAllPlaylists = _memoize(getPaginatedItems); 26 | const memoGetLikedSong = _memoize(getLikedSong); 27 | 28 | export default token => ( 29 | withApiErrorHandling(async (search) => { 30 | if (!token) { return; } 31 | 32 | const playlists = await memoGetAllPlaylists(token, 'me/playlists'); 33 | const likedSongs = await memoGetLikedSong(token); 34 | 35 | const allPlaylists = likedSongs.length 36 | ? [{ name: 'Liked Songs', id: LIKED_PLAYLIST_ID, images: [{ url: likedSongsImage }] }, ...playlists] 37 | : playlists; 38 | 39 | return filterPlaylists(allPlaylists, search); 40 | }) 41 | ); 42 | -------------------------------------------------------------------------------- /src/pages/Main/api/searchRelatedTracks.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import _uniqBy from 'lodash.uniqby'; 3 | import getRemixSearchTerm from './utils/getRemixSearchTerm'; 4 | 5 | function normaliseTracks({ tracks, trackId, acousticRemix }) { 6 | // It seems there are some duplicate tracks with different ids. Let's unique on album + track name 7 | return _uniqBy( 8 | tracks.filter(track => ( 9 | track.id !== trackId && track.name.toLowerCase().includes(acousticRemix ? 'acoustic' : 'remix') 10 | )), 11 | track => [track.album?.name, track.name].join() 12 | ); 13 | } 14 | 15 | async function search({ token, query }) { 16 | const { data: { tracks: { items } } } = await axios.get( 17 | `https://api.spotify.com/v1/search?limit=10&type=track&q=${query}`, 18 | { headers: { Authorization: `Bearer ${token}`} } 19 | ); 20 | 21 | return items; 22 | } 23 | 24 | export default async ({ token, track, fallback = false, acousticRemix }) => { 25 | if (!token) { return; } 26 | 27 | const artistName = track.artists?.[0]?.name || ''; 28 | const trackName = track.name; 29 | const trackId = track.id; 30 | 31 | await new Promise(resolve => setTimeout(resolve, 250)); 32 | 33 | try { 34 | const items = await search({ token, query: getRemixSearchTerm({ trackName, artistName, acousticRemix }) }); 35 | 36 | if (items.length || !fallback) { 37 | return { items: normaliseTracks({ tracks: items, trackId, acousticRemix }), fallback: false }; 38 | } 39 | 40 | const fallbackItems = await search({ token, query: getRemixSearchTerm({ trackName, acousticRemix }) }); 41 | 42 | return { items: normaliseTracks({ tracks: fallbackItems, trackId, acousticRemix} ), fallback: true }; 43 | } catch { 44 | return { items: [], error: true }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/pages/Main/api/utils/getPaginatedItems.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const MAX_LIMIT = 50; 4 | 5 | async function getItems({ token, limit, offset = 0, api }) { 6 | const { data: { items, total } } = await axios.get( 7 | `https://api.spotify.com/v1/${api}?limit=${limit}&offset=${offset}`, 8 | { headers: { Authorization: `Bearer ${token}`} } 9 | ); 10 | 11 | return { items, total }; 12 | } 13 | 14 | export default async (token, api) => { 15 | const { total } = await getItems({ limit: 1, token, api }); 16 | 17 | if (total <= MAX_LIMIT) { 18 | const result = await getItems({ limit: MAX_LIMIT, token, api }); 19 | 20 | return result.items; 21 | } 22 | 23 | const requests = [ 24 | ...new Array(Math.ceil(total / MAX_LIMIT)) 25 | ].map((_, index) => getItems({ limit: MAX_LIMIT, offset: MAX_LIMIT * index, token, api })) 26 | 27 | const allItems = await Promise.all(requests); 28 | const merged = allItems.reduce((all, { items }) => [...all, ...items], []); 29 | 30 | return merged; 31 | }; -------------------------------------------------------------------------------- /src/pages/Main/api/utils/getRemixSearchTerm.js: -------------------------------------------------------------------------------- 1 | export default function getRemixSearchTerm({ trackName, artistName, acousticRemix }) { 2 | const normalisedTrack = trackName 3 | .toLowerCase() 4 | .replace(/\(.*\)|-.*|from (.)+ soundtrack|radio edit|feat.+|ft.+|remix|remixed|’|'/g, '') 5 | .trim() 6 | .replace(/ {2}/g, ' '); 7 | 8 | return `${normalisedTrack}${artistName ? ` ${artistName.toLowerCase()}` : ''} ${acousticRemix ? 'acoustic' : 'remix'}`; 9 | } -------------------------------------------------------------------------------- /src/pages/Main/components/Accordion.js: -------------------------------------------------------------------------------- 1 | import { useVisibilityState } from 'webrix/hooks'; 2 | import Collapse from '@kunukn/react-collapse'; 3 | import cx from 'classnames'; 4 | import { Icon, Text } from 'common/components'; 5 | import '../styles/_accordion.scss'; 6 | 7 | export default function Accordion({ title, children, className }) { 8 | const { visible, toggle } = useVisibilityState(false); 9 | 10 | return ( 11 |
18 | 24 | 25 |
26 | {children} 27 |
28 |
29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /src/pages/Main/components/Footer.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useMediaQuery } from 'react-responsive'; 3 | import cx from 'classnames'; 4 | import { Button, Icon } from 'common/components'; 5 | 6 | function useOnScreen(ref, rootMargin = "0px") { 7 | // State and setter for storing whether element is visible 8 | const [isIntersecting, setIntersecting] = useState(false); 9 | 10 | useEffect(() => { 11 | const observer = new IntersectionObserver( 12 | ([entry]) => { 13 | // Update our state when observer callback fires 14 | setIntersecting(entry.isIntersecting); 15 | }, 16 | { 17 | rootMargin, 18 | } 19 | ); 20 | if (ref.current) { 21 | observer.observe(ref.current); 22 | } 23 | return () => { 24 | if (!ref.current) { return; } 25 | 26 | observer.unobserve(ref.current); 27 | }; 28 | }, []); // Empty array ensures that effect is only run on mount and unmount 29 | return isIntersecting; 30 | } 31 | 32 | export default function Footer({ onReset, tracks, saving, onSave, remixListMap, name, bottomRef }) { 33 | const isMobile = useMediaQuery({ maxWidth: 700 }); 34 | const onScreen = useOnScreen(bottomRef, isMobile ? '-100px' : void 0); 35 | 36 | const onScroll = to => () => { 37 | if (to === 'top') { 38 | return void window.scrollTo({ top: 0, behavior: 'smooth' }); 39 | } 40 | 41 | window.scrollTo({ 42 | top: document.querySelector('.playlist__inner').getBoundingClientRect().height, 43 | behavior: 'smooth' 44 | }) 45 | }; 46 | 47 | return ( 48 |
49 | { 50 | tracks.length ? ( 51 | 55 | Scroll To {onScreen ? 'Top' : 'Bottom'} 56 | 57 | 58 | ) 59 | : null 60 | } 61 |
62 | 65 | 72 |
73 |
74 | ); 75 | } -------------------------------------------------------------------------------- /src/pages/Main/components/Header.js: -------------------------------------------------------------------------------- 1 | import { Menu, MenuItem } from '@szhsin/react-menu'; 2 | import '@szhsin/react-menu/dist/index.css'; 3 | import '@szhsin/react-menu/dist/transitions/slide.css'; 4 | import { useScrollPosition } from '@n8tb1t/use-scroll-position'; 5 | import { useBooleanState } from 'webrix/hooks'; 6 | import cx from 'classnames'; 7 | import { useMediaQuery } from 'react-responsive'; 8 | import { ReactComponent as Logo } from '../../../assets/images/logo.svg'; 9 | import { ReactComponent as LogoIcon } from '../../../assets/images/logo-icon.svg'; 10 | import { Avatar, Icon, Text } from '../../../common/components'; 11 | import '../styles/_header.scss'; 12 | 13 | export default function Header({ stage, title, profile, signOut }) { 14 | const isMobile = useMediaQuery({ maxWidth: 700 }); 15 | const smallLogo = useMediaQuery({ maxWidth: 950 }); 16 | 17 | const { value: small, setFalse: setLarge, setTrue: setSmall } = useBooleanState(false); 18 | 19 | useScrollPosition(({ currPos: { y } }) => { 20 | const isLarge = y > -(isMobile ? 10 : 30); 21 | 22 | if (isLarge && !small) { return; } 23 | 24 | if (isLarge) { return void setLarge(); } 25 | 26 | setSmall(); 27 | }); 28 | 29 | return ( 30 |
31 | {smallLogo ? : } 32 |
33 | {stage} 34 | {title} 35 |
36 |
37 | } 41 | transition 42 | > 43 | Sign Out 44 | 45 |
46 |
47 | ); 48 | } -------------------------------------------------------------------------------- /src/pages/Main/components/Main.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Playlist from './Playlist'; 3 | import Search from './Search'; 4 | import Header from './Header'; 5 | import { ReactComponent as Waves } from '../../../assets/images/wave.svg'; 6 | import getPlaylistTracks from '../api/getPlaylistTracks'; 7 | import Success from './Success'; 8 | import '../styles/_main.scss'; 9 | import { Helmet } from 'react-helmet'; 10 | 11 | const STAGES = [ 12 | 'Find a playlist', 13 | 'Tailor your remixes', 14 | 'Groove to the beat' 15 | ]; 16 | 17 | export default function Main({ token, profile, signOut }) { 18 | const [stage, setStage] = useState(1); 19 | const [currentPlaylist, setPlaylist] = useState(null); 20 | const [tracks, setTracks] = useState([]); 21 | const [playlistId, setPlaylistId] = useState(''); 22 | const [acousticRemix, setAcousticRemix] = useState(false); 23 | 24 | const onReset = () => { 25 | setPlaylist(null); 26 | setStage(1); 27 | }; 28 | 29 | const onRemix = async () => { 30 | const retrievedTracks = await getPlaylistTracks({ token, playlistId: currentPlaylist.id }); 31 | 32 | setTracks(retrievedTracks); 33 | setStage(2); 34 | }; 35 | 36 | const onSuccess = (newPlaylistId) => { 37 | setPlaylistId(newPlaylistId); 38 | setStage(3); 39 | }; 40 | 41 | return ( 42 |
43 | 44 |
50 | { 51 | stage === 1 52 | ? ( 53 | 61 | ) 62 | : null 63 | } 64 | { 65 | stage === 2 66 | ? ( 67 | 76 | ) 77 | : null 78 | } 79 | { 80 | stage === 3 81 | ? 82 | : null 83 | } 84 | 85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /src/pages/Main/components/Pagination.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import cx from 'classnames'; 3 | import { Button } from 'common/components'; 4 | import '../styles/_pagination.scss'; 5 | 6 | export default function Pagination({ 7 | className, 8 | page, 9 | nextPage, 10 | previousPage, 11 | atCeiling, 12 | atFloor, 13 | totalPages, 14 | setPage 15 | }) { 16 | useEffect(() => { 17 | window.scrollTo({ top: 0 }); 18 | }, [page]) 19 | 20 | if (!totalPages || totalPages === 1) { return null; } 21 | 22 | const onSpecificPage = number => () => { 23 | setPage(number); 24 | } 25 | 26 | return ( 27 |
28 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/pages/Main/components/Playlist.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import cx from 'classnames'; 3 | import { useMediaQuery } from 'react-responsive'; 4 | import { useBooleanState } from 'webrix/hooks'; 5 | import { createPortal } from 'react-dom'; 6 | import AutosizeInput from 'react-input-autosize'; 7 | import { Button, Icon, OpenOnSpotifyButton, Text, Toggle } from 'common/components'; 8 | import searchRelatedTracks from '../api/searchRelatedTracks'; 9 | import createRemixPlaylist from '../api/createRemixPlaylist'; 10 | import Accordion from './Accordion'; 11 | import CheckBox from '../../../common/components/CheckBox'; 12 | import useNumberFlow from '../../../common/hooks/useNumberFlow'; 13 | import Pagination from './Pagination'; 14 | import postEvent from '../../../common/utils/postEvent'; 15 | import Footer from './Footer'; 16 | import '../styles/_playlist.scss'; 17 | 18 | const MAX_RESULTS = 20; 19 | 20 | function AlbumArt({ url, loading, previewUrl, error }) { 21 | const { value: playing, toggle: togglePlaying, setFalse: stopPlaying } = useBooleanState(); 22 | 23 | useEffect(() => { 24 | if (!playing) { return; } 25 | 26 | stopPlaying(); 27 | }, [previewUrl]); 28 | 29 | return ( 30 |
31 | {!loading && !error && !url ? : null} 32 | {loading ? : null} 33 | {error ? : null} 34 | {url && !loading ? album-art : null} 35 | {previewUrl && ( 36 |
37 | 38 |
39 | )} 40 | { 41 | playing 42 | ? ( 43 | 46 | ) 47 | : null 48 | } 49 |
50 | ); 51 | } 52 | 53 | function Track({ id, name, albumName, albumUrl, fallback, error, loading, noMatch, previewUrl, fade }) { 54 | return ( 55 |
56 | 57 | { 58 | loading || noMatch || error 59 | ? ( 60 |
61 | {!error ?

{loading ? 'Finding you a remix' : 'No remixes found'}

: null} 62 | {error ?

Something went wrong

: null} 63 |
64 | ) 65 | : ( 66 |
67 |
68 | {name} 69 | {fallback ? : null} 70 |
71 | {albumName && {albumName}} 72 |
73 | 74 |
75 |
76 | ) 77 | } 78 |
79 | ); 80 | } 81 | 82 | function RemixedTrack({ selection = [], trackId, loading, noMatch, fallback, error, trackListMap }) { 83 | const initialTrackIndex = trackListMap.get(trackId) 84 | ? selection.findIndex(track => track.uri === trackListMap.get(trackId).track) 85 | : 0; 86 | 87 | const { 88 | next, 89 | previous, 90 | number, 91 | atCeiling, 92 | atFloor 93 | } = useNumberFlow({ 94 | initialNumber: initialTrackIndex >= 0 ? initialTrackIndex : 0, 95 | max: selection?.length && selection?.length - 1 96 | }); 97 | const track = selection[number]; 98 | 99 | useEffect(() => { 100 | if (!track) { return; } 101 | 102 | trackListMap.set(trackId, { 103 | ...(trackListMap.get(trackId) || { enabled: true }), 104 | track: track.uri 105 | }); 106 | }, [track]); 107 | 108 | return ( 109 | <> 110 | 121 | { 122 | selection.length > 1 123 | ? ( 124 |
125 |
128 | ) : null 129 | } 130 | 131 | ); 132 | } 133 | 134 | function TrackRow({ track, remixListMap, trackListMap, isSmall, onRetryTrack }) { 135 | const remixedTrack = remixListMap.get(track.id); 136 | const loadingRemixTrack = !remixedTrack; 137 | const noMatch = !remixedTrack?.items.length && !loadingRemixTrack; 138 | const trackInList = trackListMap.get(track.id); 139 | const { value, setFalse, setTrue, toggle } = useBooleanState(trackInList ? trackInList.enabled : false); 140 | 141 | useEffect(() => { 142 | if (noMatch) { return void setFalse(); } 143 | 144 | setTrue(); 145 | }, [noMatch]); 146 | 147 | const onChangeEnabled = () => { 148 | toggle(); 149 | 150 | trackListMap.set(track.id, { 151 | ...(trackListMap.get(track.id) || { track: remixedTrack?.items[0]?.uri }), 152 | enabled: !value 153 | }); 154 | }; 155 | 156 | const error = remixedTrack?.error; 157 | 158 | return ( 159 |
171 | 177 | 184 |
185 | 186 |
187 | 196 | {error && ( 197 | 200 | )} 201 |
202 | ); 203 | } 204 | 205 | export default function Playlist({ token, playlist, profileId, onReset, tracks, onSuccess, acousticRemix }) { 206 | const { value: publicPlaylist, toggle: togglePublic } = useBooleanState(); 207 | const [name, setName] = useState(`${playlist.name} vol. 2`); 208 | const [finishedRemixing, setFinisheRemixing] = useState(false); 209 | const isMobile = useMediaQuery({ maxWidth: 700 }); 210 | const isSmall = useMediaQuery({ maxWidth: 1030 }); 211 | const totalPages = Math.ceil(tracks.length / MAX_RESULTS); 212 | const { 213 | number: page, 214 | next: nextPage, 215 | previous: previousPage, 216 | atCeiling, 217 | atFloor, 218 | setNumber: setPage 219 | } = useNumberFlow({ initialNumber: 1, min: 1, max: totalPages }); 220 | const { value: saving, setTrue: setSaving, setFalse: setNotSaving } = useBooleanState(); 221 | const [trackListMap] = useState(new Map()); 222 | const [remixListMap] = useState(new Map()); 223 | const [remixLastUpdated, onRemixUpdated] = useState(null); 224 | const bottomRef = useRef(); 225 | 226 | const handleTracks = async (retrievedTracks) => { 227 | for (let track of retrievedTracks) { 228 | const remixed = await searchRelatedTracks({ token, track, acousticRemix }); 229 | 230 | remixListMap.set(track.id, remixed); 231 | 232 | onRemixUpdated(Date.now()); 233 | } 234 | }; 235 | 236 | useEffect(() => { 237 | handleTracks(tracks); }, 238 | []); 239 | 240 | const onRetryTrack = track => async () => { 241 | remixListMap.delete(track.id); 242 | 243 | onRemixUpdated(Date.now()); 244 | 245 | const remixed = await searchRelatedTracks({ token, track, acousticRemix }); 246 | 247 | remixListMap.set(track.id, remixed); 248 | 249 | onRemixUpdated(Date.now()); 250 | } 251 | 252 | const onSave = async () => { 253 | const tracks = Array.from(trackListMap).reduce((trackList, [, { enabled, track }]) => { 254 | if (!enabled) { return trackList; } 255 | 256 | return [...trackList, track]; 257 | }, []); 258 | 259 | if (!tracks.length) { return; } 260 | 261 | setSaving(); 262 | 263 | try { 264 | const newPlaylistId = await createRemixPlaylist({ 265 | token, 266 | profileId, 267 | playlistName: name, 268 | remixedTracks: tracks, 269 | publicPlaylist 270 | }); 271 | 272 | postEvent('playlist-saved'); 273 | 274 | onSuccess(newPlaylistId); 275 | } catch { 276 | setNotSaving(); 277 | } 278 | }; 279 | 280 | const progressPercent = (remixListMap.size / tracks.length) * 100; 281 | 282 | useEffect(() => { 283 | if (progressPercent !== 100) { return; } 284 | 285 | setTimeout(() => { setFinisheRemixing(true); }, 2000); 286 | }, [remixLastUpdated]) 287 | 288 | const header = document.querySelector('.header'); 289 | const paginatedTracks = tracks.slice((page - 1) * MAX_RESULTS, page * MAX_RESULTS); 290 | 291 | return ( 292 | <> 293 | {header && createPortal( 294 |
295 | 296 | { 297 | tracks.length ? ( 298 | 299 | {finishedRemixing ? 'Found remixes for' : 'Remixing'} 300 | {`${finishedRemixing ? Array.from(remixListMap).filter(([_, { items }]) => items.length).length : remixListMap.size} / ${tracks.length} tracks`} 301 | 302 | ) : null 303 | } 304 |
, 305 | header 306 | )} 307 |
308 |
309 | { 316 | setName(value); 317 | }} 318 | /> 319 | {tracks.length ? ( 320 | 321 | {/**/} 322 | 323 | 324 | ) : null} 325 |
326 | Songs ({tracks.length}) 327 | 336 |
337 | {!tracks.length && There aren't any tracks in this playlist to remix.} 338 | {paginatedTracks.map(track => ( 339 | 348 | ))} 349 |
350 | 360 |
369 |
370 |
371 | 372 | ); 373 | } -------------------------------------------------------------------------------- /src/pages/Main/components/Search.js: -------------------------------------------------------------------------------- 1 | import { useBooleanState } from 'webrix/hooks' 2 | import searchMusic from '../api/getPlaylists'; 3 | import '../styles/_search.scss'; 4 | import { Button, Dropdown, Icon, Text, Toggle } from 'common/components'; 5 | import { useMediaQuery } from 'react-responsive'; 6 | 7 | export default function Search({ token, onSelectPlaylist, selectedPlaylist, onRemix, setAcousticRemix }) { 8 | const { value: busy, setTrue: setBusy, setFalse: setNotBusy } = useBooleanState(); 9 | const isMobile = useMediaQuery({ maxWidth: 700 }); 10 | 11 | const handleRemix = async () => { 12 | setBusy(); 13 | 14 | try { 15 | await onRemix(); 16 | } catch { 17 | setNotBusy(); 18 | } 19 | }; 20 | 21 | return ( 22 |
23 | What playlist are we remixing? 24 | You’ll be able to customise your remixes in the next step. 25 |
26 | !inputValue ? 'Ready to search.' : 'No matching playlists, try changing your search.'} 33 | loadingMessage={() => 'Finding your playlists ...'} 34 | styles={{ 35 | container: () => ({ height: isMobile ? 50 : 75 }), 36 | control: (_, { hasValue }) => ({ height: isMobile ? 50 : 75, fontSize: hasValue || isMobile ? void 0 : 20, paddingLeft: isMobile ? 5 : 20 }), 37 | indicatorsContainer: (base, { selectProps: { isLoading } }) => isMobile ? base : ({ ...base, width: isLoading ? 151 : 75, minWidth: 75 }), 38 | dropdownIndicator: (base) => isMobile ? base : ({ ...base, margin: 'auto', svg: { width: 30, height: 30 } }), 39 | menuList: base => ({ ...base, maxHeight: document.body.getBoundingClientRect().height / 3 }), 40 | loadingIndicator: base => ({ ...base, marginLeft: 20, marginRight: 20 }) 41 | }} 42 | getOptionLabel={option => ( 43 |
44 | { 45 | option.images?.[0]?.url 46 | ? playlist-art 47 | : ( 48 |
49 | 50 |
51 | ) 52 | } 53 |
54 | {option.name} 55 | {option.public ? 'Public' : 'Private'} playlist 56 |
57 |
58 | )} 59 | getOptionValue={option => option.id} 60 | /> 61 |
62 | 63 | 64 |
65 | ); 66 | } -------------------------------------------------------------------------------- /src/pages/Main/components/Success.js: -------------------------------------------------------------------------------- 1 | import { Button, OpenOnSpotifyButton, Text } from 'common/components'; 2 | import '../styles/_success.scss'; 3 | 4 | export default function Success({ playlistId, onReset }) { 5 | return ( 6 |
7 |
8 | 9 | 10 | 11 | 12 |
13 |
14 | We've gone and created your new playlist 🎉 15 |
16 | 17 | 18 |
19 |
20 | 21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /src/pages/Main/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './components/Main'; 2 | -------------------------------------------------------------------------------- /src/pages/Main/styles/_accordion.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .accordion { 4 | width: calc(100% + 40px); 5 | margin-left: -20px; 6 | padding-left: 20px; 7 | padding-right: 20px; 8 | transition: background-color 300ms ease-in-out; 9 | border-radius: 8px; 10 | 11 | @media only screen and (max-width: $mobile-width) { 12 | width: calc(100% + 20px); 13 | margin-left: -10px; 14 | padding-left: 10px; 15 | padding-right: 10px; 16 | } 17 | 18 | &--open { 19 | background: $light-grey; 20 | } 21 | 22 | &__child { 23 | padding-bottom: 20px; 24 | } 25 | 26 | &__header { 27 | display: flex; 28 | align-items: center; 29 | width: 100%; 30 | background: none; 31 | border: 0; 32 | padding: 0; 33 | cursor: pointer; 34 | height: 60px; 35 | justify-content: space-between; 36 | 37 | &__icon { 38 | width: 40px; 39 | height: 40px; 40 | background: white; 41 | display: flex; 42 | align-items: center; 43 | justify-content: center; 44 | border-radius: 50%; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/Main/styles/_header.scss: -------------------------------------------------------------------------------- 1 | @import 'mixins'; 2 | 3 | .header { 4 | @include header-padding; 5 | height: 120px; 6 | display: flex; 7 | align-items: center; 8 | position: fixed; 9 | width: 100vw; 10 | background: white; 11 | z-index: 2; 12 | transition: all 0.2s ease-in-out; 13 | 14 | @media only screen and (max-height: 750px) { 15 | height: 100px; 16 | } 17 | 18 | &--small { 19 | height: 70px; 20 | 21 | &.header--scrolled { 22 | .playlist__progress, 23 | .playlist__progress__bar { 24 | height: 4px; 25 | } 26 | 27 | .playlist__progress { 28 | bottom: -4px; 29 | } 30 | } 31 | } 32 | 33 | &--small &__title > .text { 34 | font-size: 20px !important; 35 | 36 | @media only screen and (max-width: 350px) { 37 | font-size: 16px !important; 38 | } 39 | } 40 | 41 | &--small &__title { 42 | &__number { 43 | width: 25px; 44 | height: 25px; 45 | min-width: 25px; 46 | margin-right: 10px; 47 | 48 | > .text { 49 | font-size: 13px; 50 | } 51 | } 52 | } 53 | 54 | &__avatar-container { 55 | margin-left: auto; 56 | 57 | > .avatar { 58 | transition: all 0.2s ease-in-out; 59 | margin-left: auto; 60 | } 61 | } 62 | 63 | &--small .avatar { 64 | width: 40px !important; 65 | height: 40px !important; 66 | } 67 | 68 | &--small { 69 | .playlist__progress__text { 70 | opacity: 0; 71 | } 72 | } 73 | 74 | .szh-menu { 75 | transform: translateX(-20px); 76 | } 77 | 78 | > svg { 79 | height: 50px; 80 | width: auto; 81 | transition: height 0.2s ease-in-out; 82 | } 83 | 84 | &--small { 85 | > svg { 86 | height: 40px; 87 | } 88 | } 89 | 90 | &__title { 91 | display: flex; 92 | align-items: center; 93 | position: absolute; 94 | left: 50%; 95 | transform: translateX(-50%); 96 | width: fit-content; 97 | max-width: calc(100vw - 140px); 98 | 99 | > .text { 100 | transition: font-size 0.2s ease-in-out; 101 | } 102 | 103 | &__number { 104 | margin-right: 20px; 105 | background: $text; 106 | border-radius: 50%; 107 | width: 40px; 108 | min-width: 40px; 109 | height: 40px; 110 | display: flex; 111 | align-items: center; 112 | justify-content: center; 113 | transition: all 0.2s ease-in-out; 114 | 115 | > .text { 116 | transition: font-size 0.2s ease-in-out; 117 | } 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/pages/Main/styles/_main.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex: 1; 4 | flex-direction: column; 5 | background: white; 6 | } -------------------------------------------------------------------------------- /src/pages/Main/styles/_pagination.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .pagination { 4 | display: flex; 5 | align-items: center; 6 | flex: 1; 7 | justify-content: flex-end; 8 | 9 | @media only screen and (max-width: $mobile-width) { 10 | flex: unset; 11 | width: 100%; 12 | justify-content: flex-start; 13 | } 14 | 15 | &__numbers { 16 | max-width: calc(100% - 100px); 17 | display: grid; 18 | grid-gap: 10px; 19 | grid-template-columns: repeat(auto-fit, minmax(15px, 20px)); 20 | grid-auto-rows: auto; 21 | } 22 | 23 | &__number { 24 | text-align: center; 25 | color: $text !important; 26 | cursor: pointer; 27 | font-size: 18px; 28 | transition: transform 0.1s ease-in-out; 29 | will-change: auto; 30 | width: 15px; 31 | line-height: 14px; 32 | 33 | &:focus { 34 | text-decoration: none; 35 | } 36 | 37 | &:hover { 38 | color: $text !important; 39 | text-decoration: underline; 40 | } 41 | 42 | &--current { 43 | font-weight: 800; 44 | pointer-events: none; 45 | transform: scale(1.2); 46 | padding: 0; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /src/pages/Main/styles/_playlist.scss: -------------------------------------------------------------------------------- 1 | @import 'mixins'; 2 | @import 'vars'; 3 | 4 | $small-rows: 1030px; 5 | 6 | .playlist { 7 | @include app-padding; 8 | 9 | display: flex; 10 | flex-direction: column; 11 | flex: 1; 12 | 13 | @media only screen and (max-width: $mobile-width) { 14 | .pagination { 15 | width: 100vw; 16 | margin-left: -20px; 17 | } 18 | } 19 | 20 | &__settings { 21 | margin-top: 30px; 22 | margin-bottom: 30px; 23 | 24 | @media only screen and (max-width: $mobile-width) { 25 | margin-top: 20px; 26 | margin-bottom: 20px; 27 | } 28 | } 29 | 30 | &__songs-header { 31 | display: flex; 32 | justify-content: space-between; 33 | align-items: center; 34 | 35 | @media only screen and (max-width: $mobile-width) { 36 | flex-direction: column; 37 | align-items: flex-start; 38 | margin-bottom: 20px !important; 39 | 40 | > h2 { 41 | margin-bottom: 20px; 42 | } 43 | } 44 | } 45 | 46 | &__name { 47 | > input { 48 | border: 0; 49 | border-bottom: 2px dashed $dark-grey; 50 | padding-bottom: 5px; 51 | font-weight: 500; 52 | 53 | &:focus, 54 | &:hover { 55 | border-bottom-color: $text; 56 | } 57 | } 58 | } 59 | 60 | &__inner { 61 | display: flex; 62 | flex-direction: column; 63 | flex: 1; 64 | padding-top: 40px; 65 | padding-bottom: 20px; 66 | 67 | @media only screen and (max-width: $mobile-width) { 68 | padding-top: 20px; 69 | } 70 | } 71 | 72 | &__footer { 73 | display: flex; 74 | justify-content: space-between; 75 | position: sticky; 76 | bottom: 0; 77 | background: white; 78 | padding-top: 10px; 79 | padding-bottom: 10px; 80 | width: 100%; 81 | height: 70px; 82 | 83 | &::before { 84 | content: ''; 85 | width: 100vw; 86 | height: 2px; 87 | background: $light-grey; 88 | position: fixed; 89 | left: 0; 90 | bottom: 70px; 91 | opacity: 0; 92 | transition: opacity 0.2s ease-in-out; 93 | 94 | @media only screen and (max-width: $mobile-width) { 95 | bottom: 132px; 96 | } 97 | } 98 | 99 | &--scrolled { 100 | &::before { 101 | opacity: 1; 102 | } 103 | } 104 | 105 | @media only screen and (max-width: $mobile-width) { 106 | height: 132px; 107 | flex-direction: column; 108 | padding-top: 20px; 109 | padding-bottom: 20px; 110 | margin-left: -20px; 111 | } 112 | 113 | &__link { 114 | margin-top: auto; 115 | margin-bottom: auto; 116 | display: flex; 117 | align-items: center; 118 | 119 | @media only screen and (max-width: $mobile-width) { 120 | margin-bottom: 20px; 121 | margin-left: auto; 122 | margin-right: auto; 123 | } 124 | 125 | > .icon { 126 | margin-top: 2px; 127 | margin-left: 10px; 128 | } 129 | } 130 | 131 | > div { 132 | display: flex; 133 | align-items: center; 134 | margin-left: auto; 135 | 136 | @media only screen and (max-width: $mobile-width) { 137 | width: 100%; 138 | margin-left: unset; 139 | justify-content: center; 140 | 141 | > .button { 142 | min-width: 0 !important; 143 | } 144 | } 145 | 146 | > .button:first-of-type { 147 | margin-right: 20px; 148 | } 149 | } 150 | 151 | @media only screen and (max-width: $mobile-width) { 152 | flex-direction: column; 153 | margin-left: unset; 154 | margin-top: 20px !important; 155 | 156 | > .button { 157 | width: 100%; 158 | 159 | &:first-of-type { 160 | margin-right: 0; 161 | margin-bottom: 10px; 162 | } 163 | } 164 | } 165 | } 166 | 167 | &__progress { 168 | height: 2px; 169 | width: 100vw; 170 | background: $light-grey; 171 | position: absolute; 172 | bottom: -2px; 173 | left: 0; 174 | display: flex; 175 | align-items: center; 176 | overflow: visible; 177 | z-index: 2; 178 | 179 | &__bar { 180 | display: block; 181 | height: 100%; 182 | background: $primary; 183 | transition: all 0.2s ease-in-out; 184 | } 185 | 186 | &__text { 187 | @include app-padding-r(true, 'margin'); 188 | background: white; 189 | padding-bottom: 5px; 190 | padding-left: 10px; 191 | padding-right: 10px; 192 | width: fit-content; 193 | right: -5px; 194 | position: absolute; 195 | font-weight: 600; 196 | border-radius: 4px; 197 | transition: all 0.2s ease-in-out; 198 | } 199 | } 200 | 201 | &__row { 202 | display: flex; 203 | align-items: center; 204 | height: 140px; 205 | min-height: 140px; 206 | padding: 20px; 207 | background: $light-grey; 208 | border-radius: 8px; 209 | transition: background 0.1s ease-in-out; 210 | border: 2px solid $light-grey; 211 | 212 | &--error { 213 | padding-right: 10px; 214 | 215 | @media only screen and (max-width: $small-rows) { 216 | padding-bottom: 60px; 217 | } 218 | } 219 | 220 | > .button { 221 | margin-left: 10px; 222 | 223 | @media only screen and (max-width: $small-rows) { 224 | margin-left: 0; 225 | width: 100%; 226 | margin-top: 20px; 227 | max-width: 400px; 228 | } 229 | } 230 | 231 | &:not(:last-of-type) { 232 | margin-bottom: 30px; 233 | 234 | @media only screen and (max-width: $small-rows) { 235 | margin-bottom: 50px; 236 | } 237 | } 238 | 239 | @media only screen and (max-width: $small-rows) { 240 | flex-direction: column; 241 | height: unset; 242 | padding: 30px; 243 | width: 100%; 244 | } 245 | 246 | @media only screen and (max-width: $mobile-width) { 247 | padding: 20px; 248 | } 249 | 250 | &--disabled { 251 | background: none; 252 | } 253 | 254 | &--loading { 255 | background: none; 256 | border-color: transparent; 257 | 258 | @media only screen and (max-width: $small-rows) { 259 | border-color: $light-grey; 260 | } 261 | } 262 | 263 | &--empty:not(&--error) { 264 | @media only screen and (max-width: $mobile-width) { 265 | //padding-bottom: 40px; 266 | } 267 | } 268 | 269 | &--empty { 270 | background: none; 271 | border-color: transparent; 272 | 273 | @media only screen and (max-width: $small-rows) { 274 | border-color: $light-grey; 275 | } 276 | 277 | > *:not(.button):not(.playlist__row__track) { 278 | opacity: 0.6; 279 | } 280 | } 281 | 282 | &__checkbox { 283 | @media only screen and (max-width: $small-rows) { 284 | margin: -15px -15px 10px auto; 285 | } 286 | 287 | @media only screen and (max-width: $mobile-width) { 288 | margin: -5px -5px 10px auto; 289 | } 290 | } 291 | 292 | &__nav { 293 | display: flex; 294 | align-items: center; 295 | 296 | > *:first-child:not(:last-child) { 297 | margin-right: 10px; 298 | } 299 | 300 | @media only screen and (max-width: $small-rows) { 301 | margin-top: 10px; 302 | margin-left: auto !important; 303 | margin-bottom: -20px; 304 | margin-right: -25px; 305 | 306 | > *:first-child:not(:last-child) { 307 | margin-right: 0; 308 | } 309 | } 310 | 311 | @media only screen and (max-width: $mobile-width) { 312 | margin-bottom: -10px; 313 | margin-right: -15px; 314 | } 315 | } 316 | 317 | &__arrow { 318 | margin-left: 70px; 319 | margin-right: 70px; 320 | 321 | > .icon { 322 | color: $dark-grey !important; 323 | } 324 | 325 | @media only screen and (max-width: 1250px) { 326 | margin-left: 50px; 327 | margin-right: 50px; 328 | } 329 | 330 | @media only screen and (max-width: 1150px) { 331 | margin-left: 30px; 332 | margin-right: 30px; 333 | } 334 | 335 | @media only screen and (max-width: $small-rows) { 336 | margin: 30px 0; 337 | width: 100%; 338 | display: flex; 339 | align-items: center; 340 | 341 | > .icon { 342 | margin-left: 20px; 343 | margin-right: 20px; 344 | } 345 | 346 | &::before, 347 | &::after { 348 | content: ''; 349 | display: block; 350 | height: 2px; 351 | background: $mid-grey; 352 | width: 100%; 353 | } 354 | } 355 | } 356 | 357 | &__track { 358 | display: flex; 359 | align-items: center; 360 | 361 | &--fade &__art, 362 | &--fade &__content > *:not(.no-fade) { 363 | opacity: 0.6; 364 | } 365 | 366 | @media only screen and (max-width: $small-rows) { 367 | width: 100%; 368 | height: 85px; 369 | min-height: 85px; 370 | } 371 | 372 | &__content { 373 | width: 300px; 374 | 375 | @media only screen and (max-width: 1200px) { 376 | width: 250px; 377 | } 378 | 379 | @media only screen and (max-width: 1100px) { 380 | width: 200px; 381 | } 382 | 383 | @media only screen and (max-width: $small-rows) { 384 | width: calc(100% - 100px); 385 | } 386 | 387 | @media only screen and (max-width: $mobile-width) { 388 | width: calc(100% - 60px); 389 | } 390 | 391 | @media only screen and (max-width: 400px) { 392 | width: calc(100% - 45px); 393 | } 394 | 395 | &__name { 396 | display: flex; 397 | align-items: center; 398 | 399 | > .icon { 400 | margin-left: 10px; 401 | color: $dark-grey !important; 402 | } 403 | } 404 | 405 | p { 406 | overflow: hidden; 407 | text-overflow: ellipsis; 408 | white-space: nowrap; 409 | margin: 0; 410 | } 411 | 412 | > p:not(:only-child) { 413 | font-size: 13px; 414 | margin-top: 5px; 415 | } 416 | } 417 | 418 | &__art { 419 | user-select: none; 420 | height: 80px; 421 | width: 80px; 422 | min-height: 80px; 423 | min-width: 80px; 424 | border-radius: 50%; 425 | background: $mid-grey; 426 | margin-right: 20px; 427 | display: flex; 428 | align-items: center; 429 | justify-content: center; 430 | overflow: hidden; 431 | position: relative; 432 | 433 | @media only screen and (max-width: $mobile-width) { 434 | height: 50px; 435 | width: 50px; 436 | min-height: 50px; 437 | min-width: 50px; 438 | } 439 | 440 | @media only screen and (max-width: 400px) { 441 | height: 30px; 442 | width: 30px; 443 | min-height: 30px; 444 | min-width: 30px; 445 | 446 | .icon { 447 | font-size: 12px !important; 448 | } 449 | } 450 | 451 | @media only screen and (max-width: $small-rows) { 452 | margin-right: 15px; 453 | } 454 | 455 | &__audio { 456 | position: absolute; 457 | height: 100%; 458 | width: 100%; 459 | background: rgba(0, 0, 0, 0.5); 460 | display: flex; 461 | align-items: center; 462 | justify-content: center; 463 | 464 | > .icon { 465 | @include scale-hover; 466 | } 467 | } 468 | 469 | > img { 470 | width: 100%; 471 | height: 100%; 472 | object-fit: contain; 473 | object-position: center; 474 | } 475 | } 476 | } 477 | } 478 | } -------------------------------------------------------------------------------- /src/pages/Main/styles/_search.scss: -------------------------------------------------------------------------------- 1 | @import 'mixins'; 2 | 3 | .search { 4 | @include app-padding; 5 | 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | flex: 1; 10 | flex-direction: column; 11 | padding-bottom: 0; 12 | 13 | > .button { 14 | width: 250px; 15 | min-width: 250px; 16 | } 17 | 18 | &__option-label { 19 | display: flex; 20 | align-items: center; 21 | 22 | &__avatar { 23 | width: 40px; 24 | height: 40px; 25 | min-width: 40px; 26 | background: $light-grey; 27 | border-radius: 50%; 28 | margin-right: 20px; 29 | display: flex; 30 | align-items: center; 31 | justify-content: center; 32 | } 33 | } 34 | 35 | &__search-input { 36 | width: 500px; 37 | max-width: 500px; 38 | 39 | @media only screen and (max-width: $mobile-width) { 40 | width: 100%; 41 | } 42 | } 43 | 44 | > .text { 45 | text-align: center; 46 | } 47 | 48 | > h2.text { 49 | margin-bottom: 10px; 50 | } 51 | } -------------------------------------------------------------------------------- /src/pages/Main/styles/_success.scss: -------------------------------------------------------------------------------- 1 | @import 'mixins'; 2 | 3 | @keyframes stroke { 4 | 100% { 5 | stroke-dashoffset: 0; 6 | } 7 | } 8 | 9 | @keyframes scale { 10 | 0%, 100% { 11 | transform: none; 12 | } 13 | 14 | 50% { 15 | transform: scale3d(1.1, 1.1, 1); 16 | } 17 | } 18 | 19 | @keyframes fill { 20 | 100% { 21 | box-shadow: inset 0px 0px 0px 30px $primary; 22 | } 23 | } 24 | 25 | .success { 26 | @include app-padding; 27 | 28 | margin: auto; 29 | display: flex; 30 | align-items: center; 31 | flex: 1; 32 | padding-bottom: 0; 33 | 34 | @media only screen and (max-width: $mobile-width) { 35 | flex-direction: column; 36 | justify-content: center; 37 | } 38 | 39 | &__actions { 40 | display: flex; 41 | 42 | > *:not(:last-child) { 43 | margin-right: 30px; 44 | } 45 | 46 | @media only screen and (max-width: $mobile-width) { 47 | flex-direction: column; 48 | margin-top: 30px; 49 | 50 | > * { 51 | width: 100%; 52 | 53 | &:first-child { 54 | margin-right: 0; 55 | margin-bottom: 20px; 56 | } 57 | } 58 | } 59 | } 60 | 61 | &__animation { 62 | margin-right: 40px; 63 | 64 | @media only screen and (max-width: $mobile-width) { 65 | margin-bottom: 20px; 66 | margin-right: 0; 67 | } 68 | 69 | &__checkmark { 70 | width: 120px; 71 | height: 120px; 72 | border-radius: 50%; 73 | display: block; 74 | stroke-width: 4; 75 | stroke: $primary; 76 | stroke-miterlimit: 10; 77 | box-shadow: inset 0px 0px 0px $primary; 78 | animation: fill .4s ease-in-out .4s forwards, scale .3s ease-in-out .9s both; 79 | position:relative; 80 | 81 | @media only screen and (max-width: $mobile-width) { 82 | width: 80px; 83 | height: 80px; 84 | } 85 | 86 | &__circle { 87 | stroke-dasharray: 166; 88 | stroke-dashoffset: 166; 89 | stroke-width: 4; 90 | stroke-miterlimit: 10; 91 | stroke: $primary; 92 | fill: #fff; 93 | animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards; 94 | } 95 | 96 | &__check { 97 | transform-origin: 50% 50%; 98 | stroke-dasharray: 48; 99 | stroke-dashoffset: 48; 100 | animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.8s forwards; 101 | } 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /src/pkce.js: -------------------------------------------------------------------------------- 1 | function dec2hex(dec) { 2 | return ("0" + dec.toString(16)).substr(-2); 3 | } 4 | 5 | export function generateCodeVerifier() { 6 | const array = new Uint32Array(56 / 2); 7 | window.crypto.getRandomValues(array); 8 | return Array.from(array, dec2hex).join(""); 9 | } 10 | 11 | function sha256(plain) { 12 | // returns promise ArrayBuffer 13 | const encoder = new TextEncoder(); 14 | const data = encoder.encode(plain); 15 | return window.crypto.subtle.digest("SHA-256", data); 16 | } 17 | 18 | function base64urlencode(a) { 19 | let str = ""; 20 | 21 | const bytes = new Uint8Array(a); 22 | const len = bytes.byteLength; 23 | 24 | for (let i = 0; i < len; i++) { 25 | str += String.fromCharCode(bytes[i]); 26 | } 27 | 28 | return btoa(str) 29 | .replace(/\+/g, "-") 30 | .replace(/\//g, "_") 31 | .replace(/=+$/, ""); 32 | } 33 | 34 | export async function generateCodeChallengeFromVerifier(v) { 35 | const hashed = await sha256(v); 36 | 37 | return base64urlencode(hashed); 38 | } -------------------------------------------------------------------------------- /src/service-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-globals */ 2 | 3 | import { clientsClaim } from 'workbox-core'; 4 | import { ExpirationPlugin } from 'workbox-expiration'; 5 | import { precacheAndRoute, createHandlerBoundToURL } from 'workbox-precaching'; 6 | import { registerRoute } from 'workbox-routing'; 7 | import { StaleWhileRevalidate } from 'workbox-strategies'; 8 | 9 | clientsClaim(); 10 | 11 | // Precache all of the assets generated by your build process. 12 | // Their URLs are injected into the manifest variable below. 13 | // This variable must be present somewhere in your service worker file, 14 | // even if you decide not to use precaching. See https://cra.link/PWA 15 | precacheAndRoute(self.__WB_MANIFEST); 16 | 17 | // Set up App Shell-style routing, so that all navigation requests 18 | // are fulfilled with your index.html shell. Learn more at 19 | // https://developers.google.com/web/fundamentals/architecture/app-shell 20 | const fileExtensionRegexp = new RegExp('/[^/?]+\\.[^/]+$'); 21 | registerRoute( 22 | // Return false to exempt requests from being fulfilled by index.html. 23 | ({ request, url }) => { 24 | // If this isn't a navigation, skip. 25 | if (request.mode !== 'navigate') { 26 | return false; 27 | } // If this is a URL that starts with /_, skip. 28 | 29 | if (url.pathname.startsWith('/_')) { 30 | return false; 31 | } // If this looks like a URL for a resource, because it contains // a file extension, skip. 32 | 33 | if (url.pathname.match(fileExtensionRegexp)) { 34 | return false; 35 | } // Return true to signal that we want to use the handler. 36 | 37 | return true; 38 | }, 39 | createHandlerBoundToURL(process.env.PUBLIC_URL + '/index.html') 40 | ); 41 | 42 | // An example runtime caching route for requests that aren't handled by the 43 | // precache, in this case same-origin .png requests like those from in public/ 44 | registerRoute( 45 | // Add in any other file extensions or routing criteria as needed. 46 | ({ url }) => ( 47 | // Logos & manifest 48 | (url.origin === self.location.origin && (url.pathname.endsWith('.png') || url.pathname.endsWith('.json')) 49 | // Icons 50 | || url.href.includes('kit.fontawesome.com') 51 | )), 52 | new StaleWhileRevalidate({ 53 | cacheName: 'files', 54 | plugins: [ 55 | // Ensure that once this runtime cache reaches a maximum size the 56 | // least-recently used images are removed. 57 | new ExpirationPlugin({ maxEntries: 50 }), 58 | ], 59 | }) 60 | ); 61 | 62 | // This allows the web app to trigger skipWaiting via 63 | // registration.waiting.postMessage({type: 'SKIP_WAITING'}) 64 | self.addEventListener('message', (event) => { 65 | if (event.data && event.data.type === 'SKIP_WAITING') { 66 | self.skipWaiting(); 67 | } 68 | }); 69 | 70 | // Any other custom service worker logic can go here. 71 | -------------------------------------------------------------------------------- /src/serviceWorkerRegistration.js: -------------------------------------------------------------------------------- 1 | const isLocalhost = Boolean( 2 | window.location.hostname === 'localhost' || 3 | // [::1] is the IPv6 localhost address. 4 | window.location.hostname === '[::1]' || 5 | // 127.0.0.0/8 are considered localhost for IPv4. 6 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 7 | ); 8 | 9 | export function register(config) { 10 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 11 | // The URL constructor is available in all browsers that support SW. 12 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 13 | if (publicUrl.origin !== window.location.origin) { 14 | // Our service worker won't work if PUBLIC_URL is on a different origin 15 | // from what our page is served on. This might happen if a CDN is used to 16 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 17 | return; 18 | } 19 | 20 | window.addEventListener('load', () => { 21 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 22 | 23 | if (isLocalhost) { 24 | // This is running on localhost. Let's check if a service worker still exists or not. 25 | checkValidServiceWorker(swUrl, config); 26 | 27 | // Add some additional logging to localhost, pointing developers to the 28 | // service worker/PWA documentation. 29 | navigator.serviceWorker.ready.then(() => { 30 | console.log( 31 | 'This web app is being served cache-first by a service ' + 32 | 'worker. To learn more, visit https://cra.link/PWA' 33 | ); 34 | }); 35 | } else { 36 | // Is not localhost. Just register service worker 37 | registerValidSW(swUrl, config); 38 | } 39 | }); 40 | } 41 | } 42 | 43 | function registerValidSW(swUrl, config) { 44 | navigator.serviceWorker 45 | .register(swUrl) 46 | .then((registration) => { 47 | registration.onupdatefound = () => { 48 | const installingWorker = registration.installing; 49 | if (installingWorker == null) { 50 | return; 51 | } 52 | installingWorker.onstatechange = () => { 53 | if (installingWorker.state === 'installed') { 54 | if (navigator.serviceWorker.controller) { 55 | // At this point, the updated precached content has been fetched, 56 | // but the previous service worker will still serve the older 57 | // content until all client tabs are closed. 58 | console.log( 59 | 'New content is available and will be used when all ' + 60 | 'tabs for this page are closed. See https://cra.link/PWA.' 61 | ); 62 | 63 | // Execute callback 64 | if (config && config.onUpdate) { 65 | config.onUpdate(registration); 66 | } 67 | } else { 68 | // At this point, everything has been precached. 69 | // It's the perfect time to display a 70 | // "Content is cached for offline use." message. 71 | console.log('Content is cached for offline use.'); 72 | 73 | // Execute callback 74 | if (config && config.onSuccess) { 75 | config.onSuccess(registration); 76 | } 77 | } 78 | } 79 | }; 80 | }; 81 | }) 82 | .catch((error) => { 83 | console.error('Error during service worker registration:', error); 84 | }); 85 | } 86 | 87 | function checkValidServiceWorker(swUrl, config) { 88 | // Check if the service worker can be found. If it can't reload the page. 89 | fetch(swUrl, { 90 | headers: { 'Service-Worker': 'script' }, 91 | }) 92 | .then((response) => { 93 | // Ensure service worker exists, and that we really are getting a JS file. 94 | const contentType = response.headers.get('content-type'); 95 | if ( 96 | response.status === 404 || 97 | (contentType != null && contentType.indexOf('javascript') === -1) 98 | ) { 99 | // No service worker found. Probably a different app. Reload the page. 100 | navigator.serviceWorker.ready.then((registration) => { 101 | registration.unregister().then(() => { 102 | window.location.reload(); 103 | }); 104 | }); 105 | } else { 106 | // Service worker found. Proceed as normal. 107 | registerValidSW(swUrl, config); 108 | } 109 | }) 110 | .catch(() => { 111 | console.log('No internet connection found. App is running in offline mode.'); 112 | }); 113 | } 114 | 115 | export function unregister() { 116 | if ('serviceWorker' in navigator) { 117 | navigator.serviceWorker.ready 118 | .then((registration) => { 119 | registration.unregister(); 120 | }) 121 | .catch((error) => { 122 | console.error(error.message); 123 | }); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/styles/_animations.scss: -------------------------------------------------------------------------------- 1 | @import 'mixins'; 2 | 3 | @keyframes spin { 4 | from { 5 | transform:rotate(0deg); 6 | } 7 | to { 8 | transform:rotate(360deg); 9 | } 10 | } 11 | 12 | .spin { 13 | animation: spin 3s linear infinite; 14 | } 15 | 16 | .scale-hover { 17 | @include scale-hover; 18 | } -------------------------------------------------------------------------------- /src/styles/_app.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | .app { 4 | &__reauth-modal { 5 | &__content { 6 | display: flex; 7 | align-items: center; 8 | margin-bottom: 40px; 9 | 10 | > img { 11 | border-radius: 50px; 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /src/styles/_index.scss: -------------------------------------------------------------------------------- 1 | // Node module styles 2 | @import '~animate.css/animate.css'; 3 | @import '~react-toggle/style.css'; 4 | @import '~rodal/lib/rodal.css'; 5 | @import '~noty/lib/noty.css'; 6 | @import '~noty/lib/themes/metroui.css'; 7 | 8 | // Custom scss files 9 | @import 'vars'; 10 | @import './_typography'; 11 | @import './_animations'; 12 | @import './_overrides'; 13 | @import './utils/_index'; 14 | 15 | body { 16 | margin: 0; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | width: 100vw; 20 | overflow-x: hidden; 21 | overflow-y: overlay; 22 | } 23 | 24 | body, 25 | html, 26 | #root { 27 | display: flex; 28 | flex-direction: column; 29 | height: 100%; 30 | min-height: 100vh; 31 | } 32 | 33 | * { 34 | outline-color: $text; 35 | box-sizing: border-box; 36 | } 37 | 38 | [data-whatintent='mouse'] *:focus, 39 | [data-whatintent='touch'] *:focus { 40 | outline: none; 41 | } 42 | -------------------------------------------------------------------------------- /src/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | @mixin scale-hover { 4 | cursor: pointer; 5 | will-change: transform; 6 | transition: transform 0.2s ease-in-out; 7 | 8 | &:hover { 9 | transform: scale(1.1); 10 | } 11 | } 12 | 13 | @mixin header-padding($allow-large: true) { 14 | padding-left: 40px; 15 | padding-right: 40px; 16 | 17 | @if $allow-large == true { 18 | @media only screen and (min-width: $large-width) { 19 | padding-left: 80px; 20 | padding-right: 80px; 21 | } 22 | } 23 | 24 | @media only screen and (max-width: $mobile-width) { 25 | padding-left: 20px; 26 | padding-right: 20px; 27 | } 28 | } 29 | 30 | @mixin app-padding-l($allow-large: true) { 31 | padding-left: 40px; 32 | 33 | @media only screen and (max-width: $mobile-width) { 34 | padding-left: 20px; 35 | } 36 | 37 | @if $allow-large == true { 38 | @media only screen and (min-width: $large-width) { 39 | padding-left: 80px; 40 | } 41 | } 42 | } 43 | 44 | @mixin app-padding-r($allow-large: true, $padding-type: 'padding') { 45 | #{$padding-type}-right: 40px; 46 | 47 | @media only screen and (max-width: $mobile-width) { 48 | #{$padding-type}-right: 20px; 49 | } 50 | 51 | @if $allow-large == true { 52 | @media only screen and (min-width: $large-width) { 53 | #{$padding-type}-right: 80px; 54 | } 55 | } 56 | } 57 | 58 | @mixin app-padding-t { 59 | padding-top: 120px; 60 | 61 | @media only screen and (max-height: 750px) { 62 | padding-top: 100px; 63 | } 64 | 65 | @media only screen and (max-width: $mobile-width) { 66 | padding-top: 70px; 67 | } 68 | } 69 | 70 | @mixin app-padding-b($allow-large: true) { 71 | padding-bottom: 40px; 72 | 73 | @media only screen and (max-width: $mobile-width) { 74 | padding-bottom: 20px; 75 | } 76 | 77 | @if $allow-large == true { 78 | @media only screen and (min-width: $large-width) { 79 | padding-bottom: 80px; 80 | } 81 | } 82 | } 83 | 84 | 85 | @mixin app-padding { 86 | @include app-padding-l; 87 | @include app-padding-r; 88 | @include app-padding-t; 89 | @include app-padding-b; 90 | } 91 | -------------------------------------------------------------------------------- /src/styles/_overrides.scss: -------------------------------------------------------------------------------- 1 | @import 'vars'; 2 | 3 | // Tooltips 4 | .__react_component_tooltip.show { 5 | opacity: 1 !important; 6 | font-size: 12px !important; 7 | color: $text !important; 8 | font-weight: 400 !important; 9 | max-width: 400px; 10 | padding-top: 20px; 11 | padding-bottom: 20px; 12 | text-align: left; 13 | //display: flex; 14 | flex-direction: column; 15 | align-items: flex-start; 16 | border-width: 2px; 17 | 18 | display: inline-block; 19 | width: auto; 20 | word-break: break-word; 21 | 22 | @media only screen and (max-width: 420px) { 23 | max-width: calc(100vw - 20px); 24 | } 25 | } 26 | 27 | .__react_component_tooltip.place-top::before, 28 | .__react_component_tooltip.place-bottom::before { 29 | border-left: 11px solid transparent !important; 30 | border-right: 11px solid transparent !important; 31 | margin-left: -11px !important; 32 | } 33 | 34 | .__react_component_tooltip.place-left::before, 35 | .__react_component_tooltip.place-right::before { 36 | border-top: 8px solid transparent !important; 37 | border-bottom: 8px solid transparent !important; 38 | right: -8px !important; 39 | margin-top: -7px !important; 40 | } 41 | 42 | // React-select dropdown menu 43 | body > div[class*="css-"] { 44 | z-index: 9999; 45 | } 46 | 47 | // Action Menu 48 | .szh-menu { 49 | box-shadow: rgba(0, 0, 0, 0.05) 0 6px 24px 0, rgba(0, 0, 0, 0.08) 0 0 0 1px !important; 50 | border-radius: 12px !important; 51 | 52 | &__item { 53 | height: 50px; 54 | 55 | > .icon { 56 | font-size: 20px !important; 57 | margin-right: 10px; 58 | width: 20px !important; 59 | color: $text; 60 | } 61 | 62 | &--active { 63 | background-color: $primary !important; 64 | } 65 | } 66 | } 67 | 68 | .noty_theme__metroui { 69 | border-radius: 6px !important; 70 | box-shadow: none; 71 | } 72 | 73 | .noty_theme__metroui.noty_type__warning { 74 | background: white; 75 | border: 2px solid $text; 76 | color: $text; 77 | } 78 | 79 | .noty_theme__metroui.noty_type__error { 80 | background: white; 81 | border: 2px solid $text; 82 | color: $danger; 83 | } 84 | -------------------------------------------------------------------------------- /src/styles/_vars.scss: -------------------------------------------------------------------------------- 1 | // Colours 2 | $primary: #00BA89; 3 | $secondary: #E7E9EB; 4 | 5 | $yellow: #F4C51B; 6 | 7 | $danger: #FF453A; 8 | $success: #33C759; 9 | 10 | $text: #112035; 11 | 12 | $light-grey: #F4F4F4; 13 | $mid-grey: #E0E0E0; 14 | $dark-grey: #BBBBBB; 15 | 16 | 17 | // Sizes 18 | $mobile-width: 700px; 19 | $large-width: 1600px; -------------------------------------------------------------------------------- /src/styles/utils/_index.scss: -------------------------------------------------------------------------------- 1 | @import './_margins'; 2 | -------------------------------------------------------------------------------- /src/styles/utils/_margins.scss: -------------------------------------------------------------------------------- 1 | $auto: auto; 2 | $directions: 't', 'b', 'l', 'r'; 3 | $css-property-map: ('t': margin-top, 'b': margin-bottom, 'l': margin-left, 'r': margin-right); 4 | $sizes: 0, 5, 10, 15, 20, 30, 40, 50, 60, 80, 100, $auto; 5 | 6 | @each $direction in $directions { 7 | @each $size in $sizes { 8 | .m#{$direction}-#{$size} { 9 | @if $size == $auto { 10 | #{map-get($css-property-map, $direction)}: $auto; 11 | } @else { 12 | #{map-get($css-property-map, $direction)}: #{$size}px; 13 | } 14 | } 15 | } 16 | } 17 | --------------------------------------------------------------------------------