├── .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 | 
4 | 
5 | 
6 | [](https://github.com/alexgurr/mixmello/issues)
7 | 
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 | |
|
|
36 | |
|
|
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 |
--------------------------------------------------------------------------------
/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 | ?

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 ?
: null}
45 |
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 |
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 |
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 |
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 |
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 ?

: 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 |
126 |
127 |
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 | ?

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 |
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 |
--------------------------------------------------------------------------------