├── .yarnrc ├── src ├── views │ ├── auth │ │ ├── style.css │ │ └── SpotifyCallback.jsx │ ├── common │ │ ├── suggestion │ │ │ ├── style.css │ │ │ └── Suggestion.jsx │ │ ├── errors │ │ │ ├── DefaultErrorMessage.jsx │ │ │ ├── style.css │ │ │ └── ScreenToSmall.jsx │ │ ├── top-artist │ │ │ ├── range-options.js │ │ │ ├── TopArtist.jsx │ │ │ └── style.css │ │ ├── top-track │ │ │ ├── range-options.js │ │ │ └── TopTrack.jsx │ │ ├── header │ │ │ ├── _style.css │ │ │ └── Header.jsx │ │ ├── genre │ │ │ ├── Genre.jsx │ │ │ └── style.css │ │ ├── formattednumber │ │ │ └── FormattedNumber.jsx │ │ ├── spinner │ │ │ ├── Spinner.jsx │ │ │ └── Spinner.css │ │ ├── navbar │ │ │ ├── navigation-items.js │ │ │ ├── style.css │ │ │ └── NavBar.jsx │ │ ├── componentspinner │ │ │ ├── ComponentSpinner.jsx │ │ │ └── ComponentSpinner.css │ │ ├── defaultscreens │ │ │ ├── ShowAt.jsx │ │ │ ├── HideAt.jsx │ │ │ ├── breakpointValidation.js │ │ │ ├── breakpointsConfig.js │ │ │ └── HideShow.jsx │ │ ├── track │ │ │ ├── Track.jsx │ │ │ └── style.css │ │ ├── index.js │ │ ├── artist │ │ │ ├── Artist.jsx │ │ │ └── style.css │ │ ├── footer │ │ │ ├── creator-items.js │ │ │ ├── _style.css │ │ │ └── Footer.jsx │ │ ├── user-badge │ │ │ ├── style.css │ │ │ └── UserBadge.jsx │ │ └── playlist │ │ │ ├── style.css │ │ │ └── Playlist.jsx │ ├── spotify │ │ ├── suggestions │ │ │ ├── style.css │ │ │ └── Suggestions.jsx │ │ ├── overview │ │ │ ├── style.css │ │ │ └── Overview.jsx │ │ ├── artists │ │ │ ├── style.css │ │ │ └── Artists.jsx │ │ ├── analyze │ │ │ ├── style.css │ │ │ └── Analyze.jsx │ │ ├── tracks │ │ │ ├── style.css │ │ │ └── Tracks.jsx │ │ └── genres │ │ │ ├── style.css │ │ │ └── Genres.jsx │ ├── feedback │ │ ├── style.css │ │ └── Feedback.jsx │ ├── Redirect │ │ └── Redirect.jsx │ ├── about │ │ ├── style.css │ │ └── About.jsx │ ├── App.jsx │ ├── user │ │ ├── style.css │ │ └── User.jsx │ ├── App.css │ ├── AppRouter.jsx │ ├── roadmap │ │ ├── style.css │ │ └── Roadmap.jsx │ └── landingpage │ │ ├── style.css │ │ └── Landingpage.jsx ├── style │ ├── _index.css │ ├── fonts.css │ └── variables.css ├── assets │ ├── kim.jpg │ ├── menu.png │ ├── stars.jpg │ ├── spotify.png │ ├── tobias.jpg │ ├── startscreen.jpg │ ├── twitter.svg │ ├── index.js │ ├── close.svg │ ├── github.svg │ ├── Rainbow-Vortex.svg │ ├── right.svg │ ├── user_icon.svg │ ├── statfy_logo_white.svg │ ├── statfy_logo_pink.svg │ ├── statfy_logo_purple.svg │ └── instagram.svg ├── hooks │ ├── toastHook.js │ └── useDataHook.js ├── config │ ├── config.default.js │ ├── index.js │ ├── config.js │ └── config.local.js ├── helper │ ├── genrehelper.js │ ├── authenticationhelper.js │ └── analysationhelper.js ├── index.js ├── services │ ├── spotifyservice.js │ ├── firebaseService.js │ ├── fetchservice.js │ └── authService.js ├── index.css ├── serviceWorker.js └── reset.css ├── _redirects ├── .npmrc ├── .prettierignore ├── renovate.json ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── og-image.jpg ├── ic_launcher_round.ico ├── ic_launcher_round.png ├── manifest.json └── index.html ├── .prettierrc ├── .eslintrc.json ├── .gitignore ├── sitemaps.xml ├── README.md ├── LICENSE ├── package.json └── CODE_OF_CONDUCT.md /.yarnrc: -------------------------------------------------------------------------------- 1 | pnpFallbackMode: all -------------------------------------------------------------------------------- /src/views/auth/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /src/views/common/suggestion/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /src/views/spotify/suggestions/style.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package.json 3 | package-lock.json 4 | public -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/style/_index.css: -------------------------------------------------------------------------------- 1 | @import url('variables.css'); 2 | @import url('fonts.css'); 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/public/logo512.png -------------------------------------------------------------------------------- /public/og-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/public/og-image.jpg -------------------------------------------------------------------------------- /src/assets/kim.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/src/assets/kim.jpg -------------------------------------------------------------------------------- /src/assets/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/src/assets/menu.png -------------------------------------------------------------------------------- /src/assets/stars.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/src/assets/stars.jpg -------------------------------------------------------------------------------- /src/assets/spotify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/src/assets/spotify.png -------------------------------------------------------------------------------- /src/assets/tobias.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/src/assets/tobias.jpg -------------------------------------------------------------------------------- /src/views/feedback/style.css: -------------------------------------------------------------------------------- 1 | .paragraph { 2 | max-width: fit-content; 3 | margin-bottom: 50px; 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/startscreen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/src/assets/startscreen.jpg -------------------------------------------------------------------------------- /public/ic_launcher_round.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/public/ic_launcher_round.ico -------------------------------------------------------------------------------- /public/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kimeggler/spotifystatistics/HEAD/public/ic_launcher_round.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "semi": true, 4 | "printWidth": 100, 5 | "singleQuote": true, 6 | "useTabs": false, 7 | "tabWidth": 2, 8 | "trailingComma": "all" 9 | } -------------------------------------------------------------------------------- /src/views/common/errors/DefaultErrorMessage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const DefaultErrorMessage = () => { 4 | return
Something went wrong..
; 5 | }; 6 | 7 | export default DefaultErrorMessage; 8 | -------------------------------------------------------------------------------- /src/views/common/errors/style.css: -------------------------------------------------------------------------------- 1 | .screentosmall { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | height: calc(100vh - 80px); 7 | text-align: center; 8 | } 9 | -------------------------------------------------------------------------------- /src/views/Redirect/Redirect.jsx: -------------------------------------------------------------------------------- 1 | import { useHistory } from 'react-router-dom'; 2 | 3 | function Redirect() { 4 | const history = useHistory(); 5 | 6 | history.push('/overview'); 7 | 8 | return null; 9 | } 10 | 11 | export default Redirect; 12 | -------------------------------------------------------------------------------- /src/views/common/top-artist/range-options.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | label: '1 month', 4 | value: 'short_term' 5 | },{ 6 | label: '6 months', 7 | value: 'medium_term' 8 | }, 9 | { 10 | label: 'all time', 11 | value: 'long_term' 12 | } 13 | ] -------------------------------------------------------------------------------- /src/views/common/top-track/range-options.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | label: '1 month', 4 | value: 'short_term', 5 | }, 6 | { 7 | label: '6 months', 8 | value: 'medium_term', 9 | }, 10 | { 11 | label: 'all time', 12 | value: 'long_term', 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /src/views/common/header/_style.css: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: space-between; 5 | padding: 20px; 6 | box-sizing: border-box; 7 | align-items: center; 8 | margin-bottom: 30px; 9 | } 10 | 11 | .header-link { 12 | text-decoration: none; 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/toastHook.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const toastHook = () => { 4 | const [toast, setToast] = useState(null); 5 | 6 | const addToast = message => { 7 | setToast(message); 8 | }; 9 | 10 | return { 11 | toast, 12 | addToast, 13 | }; 14 | }; 15 | 16 | export default toastHook; 17 | -------------------------------------------------------------------------------- /src/views/common/errors/ScreenToSmall.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './style.css'; 3 | 4 | const ScreenToSmall = () => { 5 | return ( 6 |
7 |

Eine mobile Version dieser Applikation ist im Moment in Entwicklung

8 |
9 | ); 10 | }; 11 | 12 | export default ScreenToSmall; 13 | -------------------------------------------------------------------------------- /src/config/config.default.js: -------------------------------------------------------------------------------- 1 | const { protocol, hostname, port } = window.location; 2 | 3 | const origin = `${protocol}//${hostname}${port ? `:${port}` : ''}`; 4 | 5 | const config = { 6 | protocol, 7 | hostname, 8 | port, 9 | origin, 10 | spotifyAuthority: 'https://accounts.spotify.com/authorize', 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /src/views/spotify/overview/style.css: -------------------------------------------------------------------------------- 1 | .overview { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin-bottom: 25px; 6 | } 7 | .overview-title { 8 | width: 80%; 9 | text-align: center; 10 | } 11 | 12 | @media only screen and (max-width: 1000px) { 13 | .overview { 14 | text-align: center; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:react/recommended"], 7 | "parserOptions": { 8 | "ecmaFeatures": { 9 | "jsx": true 10 | }, 11 | "ecmaVersion": 12, 12 | "sourceType": "module" 13 | }, 14 | "plugins": ["react"], 15 | "rules": {} 16 | } 17 | -------------------------------------------------------------------------------- /src/views/spotify/artists/style.css: -------------------------------------------------------------------------------- 1 | .artists { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | } 6 | 7 | .artists-content { 8 | display: flex; 9 | flex-direction: row; 10 | justify-content: center; 11 | flex-wrap: wrap; 12 | margin-bottom: 25px; 13 | } 14 | 15 | .site-title { 16 | font-weight: 600; 17 | width: 100vw; 18 | text-align: center; 19 | } 20 | -------------------------------------------------------------------------------- /src/views/common/genre/Genre.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './style.css'; 3 | 4 | function Genre(genre, index) { 5 | 6 | return ( 7 |
8 |
9 |

{index + 1}

10 |
11 |
12 |

{genre.name}

13 |
14 |
15 | ); 16 | } 17 | 18 | export default Genre; -------------------------------------------------------------------------------- /src/views/spotify/analyze/style.css: -------------------------------------------------------------------------------- 1 | .analyze { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | } 6 | 7 | .analyze-content { 8 | display: flex; 9 | flex-direction: row; 10 | justify-content: center; 11 | flex-wrap: wrap; 12 | margin-bottom: 25px; 13 | } 14 | 15 | @media only screen and (max-width: 1000px) { 16 | .analyze { 17 | text-align: center; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/views/common/formattednumber/FormattedNumber.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const FormattedNumber = ({ value }) => { 5 | const formattedString = value.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1'"); 6 | return <>{formattedString}; 7 | }; 8 | 9 | FormattedNumber.propTypes = { 10 | value: PropTypes.number, 11 | }; 12 | 13 | export default FormattedNumber; 14 | -------------------------------------------------------------------------------- /.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 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | *.log -------------------------------------------------------------------------------- /sitemaps.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://statfy.xyz 5 | 2021-11-05 6 | weekly 7 | 0.9 8 | 9 | 10 | https://statfy.xyz/about 11 | 2021-11-05 12 | 0.3 13 | 14 | -------------------------------------------------------------------------------- /src/style/fonts.css: -------------------------------------------------------------------------------- 1 | * { 2 | color: var(--font-main); 3 | } 4 | 5 | h1 { 6 | font-size: 38px; 7 | font-weight: 600; 8 | line-height: 45px; 9 | margin-bottom: 30px; 10 | width: fit-content; 11 | } 12 | 13 | h2 { 14 | font-size: 48px; 15 | } 16 | 17 | h3 { 18 | font-size: 32px; 19 | font-weight: 600; 20 | } 21 | 22 | h4 { 23 | font-size: 24px; 24 | } 25 | 26 | p { 27 | font-size: 16px; 28 | line-height: 18px; 29 | margin-bottom: 5px; 30 | } 31 | -------------------------------------------------------------------------------- /src/views/common/spinner/Spinner.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cx from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | import './Spinner.css'; 5 | 6 | const Spinner = ({ className }) => { 7 | return ( 8 |
9 |
Loading...
10 |
11 | ); 12 | }; 13 | 14 | Spinner.propTypes = { 15 | className: PropTypes.string, 16 | }; 17 | 18 | export default Spinner; 19 | -------------------------------------------------------------------------------- /src/views/common/header/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { UserBadge, NavBar } from '..'; 4 | 5 | import './_style.css'; 6 | 7 | const Header = () => { 8 | return ( 9 |
10 |

11 | 12 | STATFY 13 | 14 |

15 | 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default Header; 22 | -------------------------------------------------------------------------------- /src/views/common/navbar/navigation-items.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | path: '/overview', 4 | label: 'Overview', 5 | }, 6 | { 7 | path: '/artists', 8 | label: 'Artists', 9 | }, 10 | { 11 | path: '/tracks', 12 | label: 'Tracks', 13 | }, 14 | { 15 | path: '/analyze', 16 | label: 'Playlists', 17 | }, 18 | { 19 | path: '/genres', 20 | label: 'Genres', 21 | }, 22 | { 23 | path: '/feedback', 24 | label: 'Feedback', 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | import common from './config.default'; 2 | import local from './config.local'; 3 | import production from './config'; 4 | 5 | // The config file for the required environment will be copied with the build-scripts 6 | // It's copied over the config.js 7 | 8 | const getConfig = () => { 9 | const env = process.env.NODE_ENV; 10 | if (env === 'production') { 11 | return { ...common, ...production }; 12 | } 13 | 14 | return { ...common, ...local }; 15 | }; 16 | 17 | export default getConfig(); 18 | -------------------------------------------------------------------------------- /src/config/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | environment: 'production', 3 | remoteUrl: 'https://api.spotify.com/v1/', 4 | spotifyAuthparams: { 5 | client_id: process.env.REACT_APP_CLIENT_ID, 6 | redirect_uri: `${origin}/callback`, 7 | scope: 8 | 'user-read-private user-top-read user-read-recently-played user-read-currently-playing playlist-modify-public playlist-modify-private playlist-read-collaborative user-read-play-history', 9 | show_dialog: true, 10 | }, 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /src/views/common/componentspinner/ComponentSpinner.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import cx from 'classnames'; 4 | 5 | import './ComponentSpinner.css'; 6 | 7 | const ComponentSpinner = ({ className }) => { 8 | return ( 9 |
10 |
Loading...
11 |
12 | ); 13 | }; 14 | 15 | ComponentSpinner.propTypes = { 16 | className: PropTypes.string, 17 | }; 18 | 19 | export default ComponentSpinner; 20 | -------------------------------------------------------------------------------- /src/config/config.local.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | environment: 'development', 3 | remoteUrl: 'https://api.spotify.com/v1/', 4 | spotifyAuthparams: { 5 | client_id: process.env.REACT_APP_CLIENT_ID, 6 | redirect_uri: `${origin}/callback`, 7 | scope: 8 | 'user-read-private user-top-read user-read-recently-played user-read-currently-playing playlist-modify-public playlist-modify-private playlist-read-collaborative user-read-play-history', 9 | show_dialog: true, 10 | }, 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # STATFY 2 | 3 | ### About 4 | 5 | This project was created as part of a school project. 6 | 7 | As there raising interest in our project, we decided to further develop and support this application. 8 | 9 | ## Developers 10 | 11 | The app was built by two software engineering apprentices. 12 | 13 | ## Contact 14 | 15 | If you want to get in contact please use this email: dev.statify@gmail.com 16 | 17 | [![Netlify Status](https://api.netlify.com/api/v1/badges/1fc0e1ac-4548-4467-8034-00dfe5871f9b/deploy-status)](https://app.netlify.com/sites/spotifystatistics/deploys) 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Statfy", 3 | "name": "Statfy for Spotify", 4 | "icons": [{ 5 | "src": "favicon.ico", 6 | "sizes": "64x64 32x32 24x24 16x16", 7 | "type": "image/x-icon" 8 | }, 9 | { 10 | "src": "logo192.png", 11 | "type": "image/png", 12 | "sizes": "192x192" 13 | }, 14 | { 15 | "src": "logo512.png", 16 | "type": "image/png", 17 | "sizes": "512x512" 18 | } 19 | ], 20 | "start_url": ".", 21 | "display": "standalone", 22 | "theme_color": "#000000", 23 | "background_color": "#ffffff" 24 | } -------------------------------------------------------------------------------- /src/views/about/style.css: -------------------------------------------------------------------------------- 1 | .about { 2 | width: 100vw; 3 | height: 100vh; 4 | position: relative; 5 | display: flex; 6 | flex-direction: column; 7 | padding: 120px 50px 50px 50px; 8 | box-sizing: border-box; 9 | overflow: hidden; 10 | } 11 | 12 | .purple-outline { 13 | -webkit-text-stroke: 1px var(--main-accent); 14 | } 15 | 16 | @media only screen and (max-width: 600px) { 17 | .about { 18 | width: 100vw; 19 | height: 100vh; 20 | position: relative; 21 | display: flex; 22 | flex-direction: column; 23 | padding: 120px 50px 50px 50px; 24 | box-sizing: border-box; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/views/common/defaultscreens/ShowAt.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import HideShow from './HideShow'; 5 | import breakpointValidation from './breakpointValidation'; 6 | 7 | const ShowAt = ({ breakpoint, children, className }) => ( 8 | 9 | {children} 10 | 11 | ); 12 | 13 | ShowAt.propTypes = { 14 | breakpoint: breakpointValidation, 15 | children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), 16 | className: PropTypes.string, 17 | }; 18 | 19 | export default ShowAt; 20 | -------------------------------------------------------------------------------- /src/views/common/navbar/style.css: -------------------------------------------------------------------------------- 1 | .navigation { 2 | display: flex; 3 | width: 50%; 4 | justify-content: space-around; 5 | align-items: center; 6 | } 7 | 8 | .navigation-item { 9 | text-decoration: none; 10 | width: fit-content; 11 | -webkit-transition: -webkit-font-size 0.3s; 12 | transition: -webkit-font-size 0.3s; 13 | transition: font-size 0.3s; 14 | transition: font-size 0.3s, -webkit-font-size 0.3s; 15 | } 16 | 17 | .navigation-inactive { 18 | position: relative; 19 | } 20 | 21 | .navigation-inactive:hover { 22 | font-size: 24px; 23 | } 24 | 25 | .navigation-active { 26 | font-weight: 600; 27 | font-size: 32px; 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/twitter.svg: -------------------------------------------------------------------------------- 1 | icon-twitter -------------------------------------------------------------------------------- /src/views/common/defaultscreens/HideAt.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import HideShow from './HideShow'; 5 | import breakpointValidation from './breakpointValidation'; 6 | 7 | const HideAt = ({ breakpoint, children, className, style }) => ( 8 | 9 | {children} 10 | 11 | ); 12 | 13 | HideAt.propTypes = { 14 | breakpoint: breakpointValidation, 15 | children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), 16 | style: PropTypes.shape(), 17 | className: PropTypes.string, 18 | }; 19 | 20 | export default HideAt; 21 | -------------------------------------------------------------------------------- /src/views/common/suggestion/Suggestion.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './style.css'; 4 | 5 | const Suggestion = (suggestion, index) => { 6 | let background = {}; 7 | if (suggestion.images[0]) { 8 | background = { 9 | backgroundImage: `url(${suggestion.images[0].url})`, 10 | }; 11 | } 12 | 13 | return ( 14 |
15 |
16 |
17 |

{index + 1}

18 |
19 |

{suggestion.name}

20 |
21 | ); 22 | }; 23 | 24 | export default Suggestion; 25 | -------------------------------------------------------------------------------- /src/assets/index.js: -------------------------------------------------------------------------------- 1 | export { default as close } from './close.svg'; 2 | export { default as github } from './github.svg'; 3 | export { default as instagram } from './instagram.svg'; 4 | // creators 5 | export { default as kim } from './kim.jpg'; 6 | export { default as menu_icon } from './menu.png'; 7 | export { default as background } from './Rainbow-Vortex.svg'; 8 | export { default as arrow_right } from './right.svg'; 9 | export { default as spotify } from './spotify.png'; 10 | export { default as stars } from './stars.jpg'; 11 | export { default as startscreen } from './startscreen.jpg'; 12 | export { default as tobias } from './tobias.jpg'; 13 | export { default as twitter } from './twitter.svg'; 14 | export { default as user_icon } from './user_icon.svg'; 15 | -------------------------------------------------------------------------------- /src/views/common/track/Track.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './style.css'; 3 | 4 | function Track(track, index) { 5 | return ( 6 |
7 |
8 |

{index + 1}

9 |
10 | {track.name} 11 |
12 |

13 | {track.name.length > 35 ? `${track.name.substring(0, 32)}...` : track.name} 14 |

15 |

{track.artists[0].name}

16 |
17 |
18 | ); 19 | } 20 | 21 | export default Track; 22 | -------------------------------------------------------------------------------- /src/hooks/useDataHook.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const useDataHook = request => { 4 | const [data, setData] = useState(null); 5 | const [isLoading, setIsLoading] = useState(null); 6 | const [hasError, setHasError] = useState(false); 7 | 8 | useEffect(() => { 9 | setIsLoading(true); 10 | setHasError(false); 11 | 12 | request() 13 | .then(response => { 14 | setData(response); 15 | setHasError(false); 16 | setIsLoading(false); 17 | }) 18 | .catch(() => { 19 | setData(null); 20 | setHasError(true); 21 | setIsLoading(false); 22 | }); 23 | }, [request]); 24 | 25 | return { 26 | data, 27 | isLoading, 28 | hasError, 29 | }; 30 | }; 31 | 32 | export default useDataHook; 33 | -------------------------------------------------------------------------------- /src/views/common/top-track/TopTrack.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const TopTrack = ({ background, topTrack }) => { 5 | return ( 6 |
7 |
8 |
9 |

Your favourite song

10 |

{topTrack.name}

11 |

12 | {topTrack.artists[0].name} - {topTrack.album.name} 13 |

14 |
15 |
16 | ); 17 | }; 18 | 19 | TopTrack.propTypes = { 20 | background: PropTypes.object, 21 | topTrack: PropTypes.object, 22 | }; 23 | 24 | export default TopTrack; 25 | -------------------------------------------------------------------------------- /src/views/auth/SpotifyCallback.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import { handleSignInCallback } from '../../helper/authenticationhelper'; 4 | 5 | import './style.css'; 6 | 7 | function SpotifyCallback() { 8 | const history = useHistory(); 9 | 10 | useEffect(() => { 11 | const processCallback = async () => { 12 | try { 13 | await handleSignInCallback(); 14 | history.push('/overview'); 15 | } catch (error) { 16 | console.error('Error processing callback:', error); 17 | history.push('/'); 18 | } 19 | }; 20 | 21 | processCallback(); 22 | }, [history]); 23 | 24 | return ( 25 |
26 |
27 |

Completing sign in...

28 |
29 | ); 30 | } 31 | 32 | export default SpotifyCallback; 33 | -------------------------------------------------------------------------------- /src/assets/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/views/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter, Route, Switch, withRouter } from 'react-router-dom'; 3 | import About from './about/About'; 4 | import './App.css'; 5 | import AppRouter from './AppRouter'; 6 | import SpotifyCallback from './auth/SpotifyCallback'; 7 | import Landingpage from './landingpage/Landingpage'; 8 | import Roadmap from './roadmap/Roadmap'; 9 | 10 | const App = () => { 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | ); 24 | }; 25 | 26 | export default withRouter(App); 27 | -------------------------------------------------------------------------------- /src/views/common/genre/style.css: -------------------------------------------------------------------------------- 1 | .genre { 2 | width: 350px; 3 | display: flex; 4 | justify-content: space-between; 5 | align-items: center; 6 | margin: 25px; 7 | background-color: var(--main-2-trans); 8 | border-radius: 5px; 9 | height: 80px; 10 | } 11 | 12 | .genre .left { 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | width: 80px; 17 | background-color: var(--main-2); 18 | height: 100%; 19 | border-top-left-radius: 5px; 20 | border-bottom-left-radius: 5px; 21 | } 22 | 23 | .genre .left h3 { 24 | color: var(--font-main-secondary); 25 | } 26 | 27 | .genre .right { 28 | width: 270px; 29 | height: 100%; 30 | text-align: center; 31 | display: flex; 32 | justify-content: center; 33 | align-items: center; 34 | } 35 | 36 | .genre .right p { 37 | font-size: 18px; 38 | } 39 | 40 | @media only screen and (max-width: 1185px) { 41 | .genre { 42 | margin: 25px 25px 0 25px; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/helper/genrehelper.js: -------------------------------------------------------------------------------- 1 | const mapGenres = (topGenres, includedArtistRanking = false) => { 2 | 3 | return Array.from(new Set(topGenres)) 4 | .map(genre => ({ name: genre, count: topGenres.filter(g => g === genre).length })) 5 | .sort((a, b) => b.count - a.count) 6 | .filter(genre => genre.count > (includedArtistRanking ? 2 : 1)) 7 | .slice(0, 50); 8 | } 9 | 10 | 11 | export const calcTopGenres = (topArtists) => { 12 | const topGenres = topArtists.map(artist => artist.genres).flat(); 13 | 14 | return mapGenres(topGenres); 15 | } 16 | 17 | export const calcTopGenresIncludingArtists = (topArtists) => { 18 | const topGenres = topArtists.map((artist, index) => { 19 | const multiplier = Math.abs(Math.ceil((index + 1) / (topArtists.length / 5)) - 6); 20 | let arrayToReturn = []; 21 | for (let i = 0; i < multiplier; i++) { 22 | arrayToReturn.push(...artist.genres); 23 | } 24 | return arrayToReturn; 25 | }).flat(); 26 | 27 | return mapGenres(topGenres, true); 28 | } -------------------------------------------------------------------------------- /src/assets/Rainbow-Vortex.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/common/index.js: -------------------------------------------------------------------------------- 1 | export { default as Artist } from './artist/Artist'; 2 | 3 | export { default as TopArtist } from './top-artist/TopArtist'; 4 | 5 | export { default as Track } from './track/Track'; 6 | 7 | export { default as TopTrack } from './top-track/TopTrack'; 8 | 9 | export { default as Playlist } from './playlist/Playlist'; 10 | 11 | export { default as Footer } from './footer/Footer'; 12 | 13 | export { default as Header } from './header/Header'; 14 | 15 | export { default as UserBadge } from './user-badge/UserBadge'; 16 | 17 | export { default as ShowAt } from './defaultscreens/ShowAt'; 18 | 19 | export { default as Suggestion } from './suggestion/Suggestion'; 20 | 21 | export { default as ScreenToSmall } from './errors/ScreenToSmall'; 22 | 23 | export { default as NavBar } from './navbar/NavBar'; 24 | 25 | export { default as Spinner } from './spinner/Spinner'; 26 | 27 | export { default as ComponentSpinner } from './componentspinner/ComponentSpinner'; 28 | 29 | export { default as DefaultErrorMessage } from './errors/DefaultErrorMessage'; 30 | -------------------------------------------------------------------------------- /src/views/common/navbar/NavBar.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import cx from 'classnames'; 4 | 5 | import { ShowAt } from '..'; 6 | 7 | import './style.css'; 8 | 9 | import navigationItems from './navigation-items'; 10 | 11 | const Navigation = () => { 12 | const currentPath = window.location.pathname; 13 | 14 | return ( 15 | 16 | 17 |
18 |
19 | 20 | 21 |
22 | {navigationItems.map((p, i) => ( 23 | 31 | {p.label} 32 | 33 | ))} 34 |
35 |
36 |
37 | ); 38 | }; 39 | 40 | export default Navigation; 41 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import ReactGA from 'react-ga'; 5 | import { Router } from 'react-router-dom'; 6 | import { createBrowserHistory } from 'history'; 7 | import './reset.css'; 8 | import './index.css'; 9 | import './style/_index.css'; 10 | import App from './views/App'; 11 | import * as serviceWorker from './serviceWorker'; 12 | 13 | const trackingId = 'UA-164134196-1'; 14 | ReactGA.initialize(trackingId); 15 | 16 | const history = createBrowserHistory(); 17 | 18 | history.listen(location => { 19 | ReactGA.set({ page: location.pathname }); // Update the user's current page 20 | ReactGA.pageview(location.pathname); // Record a pageview for the given page 21 | }); 22 | 23 | ReactDOM.render( 24 | 25 | 26 | , 27 | document.getElementById('root'), 28 | ); 29 | 30 | // If you want your app to work offline and load faster, you can change 31 | // unregister() to register() below. Note this comes with some pitfalls. 32 | // Learn more about service workers: https://bit.ly/CRA-PWA 33 | serviceWorker.unregister(); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 kimeggler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/assets/right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/views/common/artist/Artist.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './style.css'; 4 | 5 | const Artist = (artist, index) => { 6 | let background = {}; 7 | if (artist.images[0]) { 8 | background = { 9 | backgroundImage: `url(${artist.images[0].url})`, 10 | }; 11 | } 12 | 13 | return ( 14 |
15 |
16 |
17 |
18 |
19 | {artist.genres 20 | .slice(0, 2) 21 | .sort((f, s) => f.length - s.length) 22 | .map(g => ( 23 |
24 |

{g.toUpperCase()}

25 |
26 | ))} 27 |
28 |
29 |

{index + 1}

30 |
31 |

{artist.name}

32 |
33 | ); 34 | }; 35 | 36 | export default Artist; 37 | -------------------------------------------------------------------------------- /src/views/common/top-artist/TopArtist.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import './style.css'; 4 | 5 | const TopArtist = ({ background, topArtist }) => { 6 | console.log(topArtist); 7 | return ( 8 |
9 |
10 |

Your favourite artist

11 |

{topArtist.name}

12 |

13 | {topArtist.genres 14 | .slice(0, 2) 15 | .sort((f, s) => f.length - s.length) 16 | .map(g => ( 17 |

18 |

{g.toUpperCase()}

19 |
20 | ))} 21 |

22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | TopArtist.propTypes = { 29 | background: PropTypes.object, 30 | topArtist: PropTypes.object, 31 | }; 32 | 33 | export default TopArtist; 34 | -------------------------------------------------------------------------------- /src/views/about/About.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { stars } from '../../assets'; 3 | 4 | import './style.css'; 5 | 6 | function About() { 7 | return ( 8 |
9 |
10 |

STATFY

11 |

STATISTICS & FACTS

12 |

13 | ABOUT STATFY 14 |

15 |

16 | Statfy is a web-application based on React. It was developed as part of a non-profit 17 | schoolproject. 18 |

19 |

While using Statfy no data from your Spotify profile is stored.

20 |

*This application is not supported by Spotify.

21 | 22 | 30 |
31 | ); 32 | } 33 | 34 | export default About; 35 | -------------------------------------------------------------------------------- /src/services/spotifyservice.js: -------------------------------------------------------------------------------- 1 | import { getData } from './fetchservice'; 2 | 3 | const fetchMyProfile = async () => { 4 | const response = await getData(`me`); 5 | return response; 6 | }; 7 | 8 | const fetchMyTopArtist = async timerange => { 9 | const response = await getData(`me/top/artists`, {}, `?time_range=${timerange}&limit=1`); 10 | return response.items[0]; 11 | }; 12 | 13 | const fetchArtists = async timerange => { 14 | const response = await getData(`me/top/artists`, {}, `?time_range=${timerange}&limit=50`); 15 | return response.items; 16 | }; 17 | 18 | const fetchMyTopTrack = async timerange => { 19 | const response = await getData(`me/top/tracks`, {}, `?time_range=${timerange}&limit=1`); 20 | return response.items[0]; 21 | }; 22 | 23 | const fetchTracks = async timerange => { 24 | const response = await getData(`me/top/tracks`, {}, `?time_range=${timerange}&limit=50`); 25 | return response.items; 26 | }; 27 | 28 | const fetchPlaylists = async profile => { 29 | const response = await getData(`users/${profile.id}/playlists`, null, `?limit=50`); 30 | return response.items; 31 | }; 32 | 33 | export { 34 | fetchMyProfile, 35 | fetchMyTopArtist, 36 | fetchArtists, 37 | fetchMyTopTrack, 38 | fetchTracks, 39 | fetchPlaylists, 40 | }; 41 | -------------------------------------------------------------------------------- /src/views/user/style.css: -------------------------------------------------------------------------------- 1 | .user-image-box { 2 | position: relative; 3 | width: 350px; 4 | height: 350px; 5 | background-size: cover; 6 | border-radius: 15px; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | background-color: var(--main-2-trans); 11 | } 12 | 13 | .user-image { 14 | position: relative; 15 | width: 325px; 16 | height: 325px; 17 | background-size: cover; 18 | border-radius: 15px; 19 | } 20 | 21 | .circle-box { 22 | position: relative; 23 | width: 30px; 24 | height: 30px; 25 | display: flex; 26 | margin: 0 10px 0 0; 27 | } 28 | 29 | .location { 30 | display: flex; 31 | } 32 | 33 | .circle { 34 | position: absolute; 35 | width: 15px; 36 | height: 15px; 37 | border-radius: 50%; 38 | background-color: limegreen; 39 | opacity: 0; 40 | top: 7.5px; 41 | left: 7.5px; 42 | animation: pulse 4s infinite cubic-bezier(0.36, 0.11, 0.89, 0.32); 43 | } 44 | 45 | .c1 { 46 | animation-delay: -3s; 47 | } 48 | 49 | .c2 { 50 | animation-delay: -2s; 51 | } 52 | 53 | .c3 { 54 | animation-delay: -1s; 55 | } 56 | 57 | .c4 { 58 | animation-delay: 0s; 59 | } 60 | 61 | @keyframes pulse { 62 | from { 63 | transform: scale(0.4, 0.4); 64 | opacity: 0.5; 65 | } 66 | to { 67 | transform: scale(2, 2); 68 | opacity: 0; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/views/spotify/tracks/style.css: -------------------------------------------------------------------------------- 1 | .tracks { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | } 6 | 7 | .tracks-content { 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | margin-bottom: 25px; 12 | } 13 | 14 | .button-container { 15 | display: flex; 16 | justify-content: center; 17 | } 18 | 19 | .create-playlist-button { 20 | box-sizing: border-box; 21 | height: 30px; 22 | display: flex; 23 | align-self: flex-end; 24 | width: -moz-fit-content; 25 | width: fit-content; 26 | border-radius: 5px; 27 | background: var(--main-2-trans); 28 | font-size: 16px; 29 | padding: 7px 10px; 30 | margin-right: 15px; 31 | margin-top: -30px; 32 | } 33 | 34 | .hide { 35 | display: none; 36 | } 37 | 38 | .done { 39 | color: var(--font-main); 40 | background-color: var(--spotify_main); 41 | } 42 | 43 | .error { 44 | color: var(--main-red); 45 | border: 2px solid var(--main-red); 46 | background-color: transparent; 47 | } 48 | 49 | .create-playlist-button:hover { 50 | background-color: var(--main-2); 51 | cursor: pointer; 52 | color: var(--font-main-secondary); 53 | } 54 | 55 | @media only screen and (max-width: 1000px) { 56 | .create-playlist-button { 57 | right: 55px; 58 | position: absolute; 59 | top: 54px; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/views/common/defaultscreens/breakpointValidation.js: -------------------------------------------------------------------------------- 1 | import forEach from 'lodash/fp/forEach'; 2 | import some from 'lodash/fp/some'; 3 | 4 | import breakpointsConfig from './breakpointsConfig'; 5 | 6 | const breakpoints = (props, propName, componentName) => { 7 | let prop = props[propName]; 8 | 9 | if (!prop) { 10 | return new Error( 11 | `Invalid prop \`${propName}\` supplied to \`${componentName}\`. Breakpoint is required. Given: ${prop}`, 12 | ); 13 | } 14 | 15 | prop = prop.split(' '); 16 | 17 | if (prop.length > 2) { 18 | return new Error( 19 | `Invalid prop \`${propName}\` supplied to \`${componentName}\`. Maximum number of breakpoints is 2. Given: ${prop.length}`, 20 | ); 21 | } 22 | 23 | let isValid = true; 24 | let lastBreakpoint = ''; 25 | 26 | forEach(breakpoint => { 27 | // If breakpoint is invalid 28 | if (!some(['name', breakpoint], breakpointsConfig.breakpoints)) { 29 | isValid = false; 30 | lastBreakpoint = breakpoint; 31 | return false; 32 | } 33 | return true; 34 | }, prop); 35 | 36 | if (!isValid) { 37 | return new Error( 38 | `Invalid prop \`${propName}\` supplied to \`${componentName}\`. Invalid breakpoint name. Given: ${lastBreakpoint}`, 39 | ); 40 | } 41 | return null; 42 | }; 43 | 44 | export default breakpoints; 45 | -------------------------------------------------------------------------------- /src/views/common/spinner/Spinner.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | height: 50em; 3 | } 4 | 5 | .loader, 6 | .loader:before, 7 | .loader:after { 8 | background: #ffffff; 9 | -webkit-animation: load1 1s infinite ease-in-out; 10 | animation: load1 1s infinite ease-in-out; 11 | width: 1em; 12 | height: 4em; 13 | } 14 | 15 | .loader { 16 | color: #ffffff; 17 | text-indent: -9999em; 18 | margin: 200px auto 88px auto; 19 | position: relative; 20 | font-size: 11px; 21 | -webkit-transform: translateZ(0); 22 | -ms-transform: translateZ(0); 23 | transform: translateZ(0); 24 | -webkit-animation-delay: -0.16s; 25 | animation-delay: -0.16s; 26 | } 27 | 28 | .loader:before, 29 | .loader:after { 30 | position: absolute; 31 | top: 0; 32 | content: ''; 33 | } 34 | 35 | .loader:before { 36 | left: -1.5em; 37 | -webkit-animation-delay: -0.32s; 38 | animation-delay: -0.32s; 39 | } 40 | 41 | .loader:after { 42 | left: 1.5em; 43 | } 44 | 45 | @-webkit-keyframes load1 { 46 | 0%, 47 | 80%, 48 | 100% { 49 | box-shadow: 0 0; 50 | height: 4em; 51 | } 52 | 40% { 53 | box-shadow: 0 -2em; 54 | height: 5em; 55 | } 56 | } 57 | 58 | @keyframes load1 { 59 | 0%, 60 | 80%, 61 | 100% { 62 | box-shadow: 0 0; 63 | height: 4em; 64 | } 65 | 40% { 66 | box-shadow: 0 -2em; 67 | height: 5em; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/services/firebaseService.js: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app'; 2 | import { addDoc, collection, getFirestore } from 'firebase/firestore'; 3 | // TODO: Add SDKs for Firebase products that you want to use 4 | // https://firebase.google.com/docs/web/setup#available-libraries 5 | 6 | // Your web app's Firebase configuration 7 | // For Firebase JS SDK v7.20.0 and later, measurementId is optional 8 | const firebaseConfig = { 9 | // eslint-disable-next-line no-undef 10 | apiKey: process.env.REACT_APP_FIREBASE_API_KEY, 11 | authDomain: 'statfy.firebaseapp.com', 12 | databaseURL: 'https://statfy-default-rtdb.firebaseio.com', 13 | projectId: 'statfy', 14 | storageBucket: 'statfy.appspot.com', 15 | // eslint-disable-next-line no-undef 16 | messagingSenderId: process.env.REACT_APP_FIREBASE_SENDER_ID, 17 | // eslint-disable-next-line no-undef 18 | appId: process.env.REACT_APP_FIREBASE_APP_ID, 19 | // eslint-disable-next-line no-undef 20 | measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENT_ID, 21 | }; 22 | 23 | // Initialize Firebase 24 | const app = initializeApp(firebaseConfig); 25 | const db = getFirestore(app); 26 | const feedbackCollection = collection(db, 'feedback'); 27 | 28 | const saveFeedback = async payload => { 29 | console.log(payload); 30 | await addDoc(feedbackCollection, payload); 31 | }; 32 | 33 | export default saveFeedback; 34 | -------------------------------------------------------------------------------- /src/views/spotify/suggestions/Suggestions.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // import { fetchSuggestions } from '../../../services/spotifyservice'; 3 | // import { Suggestion, DefaultErrorMessage } from '../../common'; 4 | import './style.css'; 5 | 6 | // import { Spinner } from '../../common'; 7 | // import useDataHook from '../../../hooks/useDataHook'; 8 | 9 | function Suggestions() { 10 | // const [baseContent, setBaseContent] = ''; 11 | // const [suggestionsRequest, setSuggestionsRequest] = useState(() => () => fetchSuggestions(baseContent)); 12 | // const { data: artists, isLoading, hasError } = useDataHook(suggestionsRequest); 13 | 14 | // useEffect(() => { 15 | // setSuggestionsRequest(() => () => fetchSuggestions(baseContent)); 16 | // }, [timerange]); 17 | 18 | // if (hasError) return ; 19 | // if (!artists > 0 && isLoading !== false) return ; 20 | 21 | // const renderSuggestions = () => { 22 | // return artists.map((artist, index) => { 23 | // return Suggestion(artist, index); 24 | // }); 25 | // }; 26 | 27 | return ( 28 |
29 |

Let us show you songs you might like!

30 | 31 | {/*
{renderSuggestions()}
*/} 32 |
33 | ); 34 | } 35 | 36 | export default Suggestions; 37 | -------------------------------------------------------------------------------- /src/views/common/footer/creator-items.js: -------------------------------------------------------------------------------- 1 | import { github, instagram, twitter, kim, tobias, spotify } from '../../../assets'; 2 | 3 | export default [ 4 | { 5 | name: 'KIM EGGLER', 6 | image: kim, 7 | links: [ 8 | { 9 | name: 'GITHUB', 10 | href: 'https://github.com/kimeggler', 11 | image: github, 12 | }, 13 | { 14 | name: 'INSTAGRAM', 15 | href: 'https://instagram.com/kim.eggler', 16 | image: instagram, 17 | }, 18 | { 19 | name: 'TWITTER', 20 | href: 'https://twitter.com/kim_eggler', 21 | image: twitter, 22 | }, 23 | { 24 | name: 'SPOTIFY', 25 | href: 'https://open.spotify.com/user/kim.eggler?si=ZbVUqNSdSrGYWQ0buNkw7Q', 26 | image: spotify, 27 | }, 28 | ], 29 | }, 30 | { 31 | name: 'TOBIAS BLASER', 32 | image: tobias, 33 | links: [ 34 | { 35 | name: 'GITHUB', 36 | href: 'https://github.com/tobiasBlaser', 37 | image: github, 38 | }, 39 | { 40 | name: 'INSTAGRAM', 41 | href: 'https://instagram.com/_tobi_bl_', 42 | image: instagram, 43 | }, 44 | { 45 | name: 'SPOTIFY', 46 | href: 'https://open.spotify.com/user/toptob01?si=Tz-Iq0MjS9GXwiCQ_DkHNg', 47 | image: spotify, 48 | }, 49 | ], 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /src/views/spotify/genres/style.css: -------------------------------------------------------------------------------- 1 | .genres { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | 8 | .genres-content { 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: center; 12 | flex-wrap: wrap; 13 | } 14 | 15 | .include-ranking-container { 16 | display: flex; 17 | flex-direction: column; 18 | align-items: center; 19 | margin: 40px 20px 20px 20px; 20 | } 21 | 22 | .ranking-container { 23 | align-items: center; 24 | } 25 | .ranking-container p { 26 | margin: 0; 27 | } 28 | 29 | .artists-weighting { 30 | margin-left: 10px; 31 | transition: background-color 0.2s ease; 32 | height: 30px; 33 | width: 60px; 34 | border-radius: 15px; 35 | background-color: var(--main-2-trans); 36 | box-sizing: border-box; 37 | padding: 5px; 38 | } 39 | 40 | .artists-weighting-switch { 41 | left: 0px; 42 | transition: left 0.2s ease; 43 | height: 20px; 44 | width: 20px; 45 | border-radius: 10px; 46 | position: relative; 47 | background-color: var(--main-2); 48 | } 49 | 50 | .info-card { 51 | width: 300px; 52 | height: fit-content; 53 | background-color: var(--main-2); 54 | padding: 10px; 55 | text-align: center; 56 | border-radius: 5px; 57 | margin: 10px; 58 | } 59 | 60 | .info-card p { 61 | color: var(--font-main-dark); 62 | font-weight: bold; 63 | } 64 | -------------------------------------------------------------------------------- /src/views/common/track/style.css: -------------------------------------------------------------------------------- 1 | .track { 2 | display: flex; 3 | align-items: center; 4 | position: relative; 5 | background-color: var(--main-2-trans); 6 | margin: 25px 25px 0 25px; 7 | border-radius: 15px; 8 | height: fit-content; 9 | width: 1000px; 10 | box-sizing: border-box; 11 | } 12 | 13 | .top-section p { 14 | margin: 5px 0; 15 | } 16 | 17 | .card-image { 18 | height: 100px; 19 | width: auto; 20 | margin-right: 10px; 21 | border-radius: 15px 0 0 15px; 22 | filter: grayscale(1); 23 | } 24 | 25 | .track-index { 26 | margin-top: 5px; 27 | font-size: 20px; 28 | color: var(--font-main-white); 29 | } 30 | 31 | .track-name { 32 | margin-right: 50px !important; 33 | font-size: 18px; 34 | } 35 | 36 | .artist-name { 37 | margin-right: 50px !important; 38 | font-size: 18px; 39 | } 40 | 41 | .highlight-circle { 42 | border-radius: 0 15px 0 0; 43 | display: flex; 44 | justify-content: center; 45 | align-items: center; 46 | width: 50px; 47 | height: 50px; 48 | background-color: var(--main-1-trans); 49 | position: absolute; 50 | top: 0; 51 | right: 0; 52 | } 53 | 54 | .highlight-circle p { 55 | color: var(--font-main); 56 | } 57 | 58 | @media only screen and (max-width: 1185px) { 59 | .track { 60 | width: 80vw; 61 | } 62 | } 63 | 64 | @media only screen and (max-width: 600px) { 65 | .track { 66 | width: 350px; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/views/common/componentspinner/ComponentSpinner.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | height: 100%; 3 | width: 50%; 4 | position: relative; 5 | } 6 | 7 | .loader, 8 | .loader:before, 9 | .loader:after { 10 | background: #ffffff; 11 | -webkit-animation: load1 1s infinite ease-in-out; 12 | animation: load1 1s infinite ease-in-out; 13 | width: 1em; 14 | height: 4em; 15 | } 16 | 17 | .loader { 18 | color: #ffffff; 19 | text-indent: -9999em; 20 | margin: 200px 0 88px auto; 21 | position: relative; 22 | font-size: 11px; 23 | -webkit-transform: translateZ(0); 24 | -ms-transform: translateZ(0); 25 | transform: translateZ(0); 26 | -webkit-animation-delay: -0.16s; 27 | animation-delay: -0.16s; 28 | } 29 | 30 | .loader:before, 31 | .loader:after { 32 | position: absolute; 33 | top: 0; 34 | content: ''; 35 | } 36 | 37 | .loader:before { 38 | left: -1.5em; 39 | -webkit-animation-delay: -0.32s; 40 | animation-delay: -0.32s; 41 | } 42 | 43 | .loader:after { 44 | left: 1.5em; 45 | } 46 | 47 | @-webkit-keyframes load1 { 48 | 0%, 49 | 80%, 50 | 100% { 51 | box-shadow: 0 0; 52 | height: 4em; 53 | } 54 | 40% { 55 | box-shadow: 0 -2em; 56 | height: 5em; 57 | } 58 | } 59 | 60 | @keyframes load1 { 61 | 0%, 62 | 80%, 63 | 100% { 64 | box-shadow: 0 0; 65 | height: 4em; 66 | } 67 | 40% { 68 | box-shadow: 0 -2em; 69 | height: 5em; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/views/user/User.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { fetchMyProfile } from '../../services/spotifyservice'; 3 | import { DefaultErrorMessage, Spinner } from '../common'; 4 | import './style.css'; 5 | import useDataHook from '../../hooks/useDataHook'; 6 | 7 | function User() { 8 | let background = {}; 9 | const [userRequest, setUserRequest] = useState(() => () => fetchMyProfile()); 10 | const { data: user, isLoading, hasError } = useDataHook(userRequest); 11 | 12 | if (user && user.images[0]) { 13 | background = { 14 | backgroundImage: `url(${user.images[0].url})`, 15 | }; 16 | } 17 | 18 | useEffect(() => { 19 | setUserRequest(() => () => fetchMyProfile()); 20 | }, []); 21 | 22 | if (hasError) return ; 23 | if (!user && isLoading !== false) return ; 24 | 25 | return ( 26 |
27 |

About you!

28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |

{user?.country}

39 |
40 |
41 | ); 42 | } 43 | 44 | export default User; 45 | -------------------------------------------------------------------------------- /src/assets/user_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/helper/authenticationhelper.js: -------------------------------------------------------------------------------- 1 | import authService from '../services/authService'; 2 | 3 | const validateToken = async () => { 4 | try { 5 | const user = await authService.getUser(); 6 | if (!user) { 7 | return false; 8 | } 9 | 10 | const isExpired = await authService.isTokenExpired(); 11 | if (isExpired) { 12 | await authService.signOut(); 13 | return false; 14 | } 15 | 16 | return true; 17 | } catch (error) { 18 | console.error('Error validating token:', error); 19 | return false; 20 | } 21 | }; 22 | 23 | const getToken = async () => { 24 | try { 25 | return await authService.getAccessToken(); 26 | } catch (error) { 27 | console.error('Error getting token:', error); 28 | return null; 29 | } 30 | }; 31 | 32 | const clearToken = async () => { 33 | try { 34 | await authService.signOut(); 35 | } catch (error) { 36 | console.error('Error clearing token:', error); 37 | } 38 | }; 39 | 40 | const signIn = async () => { 41 | try { 42 | await authService.signIn(); 43 | } catch (error) { 44 | console.error('Error signing in:', error); 45 | throw error; 46 | } 47 | }; 48 | 49 | const handleSignInCallback = async () => { 50 | try { 51 | const user = await authService.signInCallback(); 52 | return user; 53 | } catch (error) { 54 | console.error('Error handling sign in callback:', error); 55 | throw error; 56 | } 57 | }; 58 | 59 | export { clearToken, getToken, handleSignInCallback, signIn, validateToken }; 60 | -------------------------------------------------------------------------------- /src/services/fetchservice.js: -------------------------------------------------------------------------------- 1 | import config from '../config'; 2 | import { getToken, signIn, validateToken } from '../helper/authenticationhelper'; 3 | 4 | const getDefaultHeaders = async () => { 5 | const token = await getToken(); 6 | return { 7 | Accept: 'application/json', 8 | 'Content-Type': 'application/json', 9 | Authorization: `Bearer ${token}`, 10 | }; 11 | }; 12 | 13 | const authorizeSpotifyUser = async () => { 14 | await signIn(); 15 | }; 16 | 17 | const getData = async (path, headers = {}, queryParams = '') => { 18 | const isValid = await validateToken(); 19 | if (!isValid) { 20 | await authorizeSpotifyUser(); 21 | return; 22 | } 23 | 24 | const defaultHeaders = await getDefaultHeaders(); 25 | return fetch(`${config.remoteUrl}${path}${queryParams !== '' ? queryParams : ''}`, { 26 | method: 'GET', 27 | headers: { 28 | ...defaultHeaders, 29 | ...headers, 30 | }, 31 | }).then(response => response.json()); 32 | }; 33 | 34 | const postData = async (path, data, headers = {}, queryParams = '') => { 35 | const isValid = await validateToken(); 36 | if (!isValid) { 37 | await authorizeSpotifyUser(); 38 | return; 39 | } 40 | 41 | const defaultHeaders = await getDefaultHeaders(); 42 | return fetch(`${config.remoteUrl}${path}${queryParams !== '' ? queryParams : ''}`, { 43 | method: 'POST', 44 | headers: { 45 | ...defaultHeaders, 46 | ...headers, 47 | }, 48 | body: data, 49 | }).then(response => response.json()); 50 | }; 51 | 52 | export { authorizeSpotifyUser, getData, postData }; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "statfy", 3 | "version": "0.3.0", 4 | "private": true, 5 | "dependencies": { 6 | "apexcharts": "^3.33.0", 7 | "classnames": "^2.3.1", 8 | "copyfiles": "2.4.1", 9 | "firebase": "^9.10.0", 10 | "history": "5.2.0", 11 | "moment": "2.29.1", 12 | "oidc-client-ts": "^3.4.1", 13 | "react": "17.0.2", 14 | "react-apexcharts": "^1.3.7", 15 | "react-dom": "17.0.2", 16 | "react-ga": "3.3.0", 17 | "react-router-dom": "5.3.0", 18 | "react-scripts": "5.0.0" 19 | }, 20 | "devDependencies": { 21 | "@testing-library/jest-dom": "5.11.4", 22 | "@testing-library/react": "11.1.0", 23 | "@testing-library/user-event": "12.1.9", 24 | "prettier": "2.1.2" 25 | }, 26 | "scripts": { 27 | "start": "react-scripts start", 28 | "build": "react-scripts build && copyfiles _redirects build/ && copyfiles sitemaps.xml build/", 29 | "test": "react-scripts test", 30 | "eject": "react-scripts eject", 31 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md,css}\"", 32 | "lint": "eslint \"**/*.{js,jsx}\"" 33 | }, 34 | "eslintConfig": { 35 | "extends": "react-app" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | }, 49 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 50 | } 51 | -------------------------------------------------------------------------------- /src/style/variables.css: -------------------------------------------------------------------------------- 1 | html { 2 | /*dark theme main colors*/ 3 | --main-1: #0c142a; 4 | --main-2: #ffffff; 5 | --main-3: #0c142a; 6 | --main-accent: #d300ff; 7 | 8 | --main-accent-pride: linear-gradient( 9 | 90deg, 10 | #fe0000 16.66%, 11 | #fd8c00 16.66%, 12 | 33.32%, 13 | #ffe500 33.32%, 14 | 49.98%, 15 | #119f0b 49.98%, 16 | 66.64%, 17 | #0644b3 66.64%, 18 | 83.3%, 19 | #c22edc 83.3% 20 | ); 21 | 22 | --main-accent-gradient: linear-gradient(-35deg, #d300ff, #8300ff); 23 | --main-accent-gradient-analyse: linear-gradient(-35deg, #d300ff 0px, #8300ff 400px); 24 | --main-background: #0b001b; 25 | --main-background-persistent: linear-gradient( 26 | -20deg, 27 | rgba(15, 226, 103, 1), 28 | rgba(180, 16, 206, 1), 29 | rgba(249, 149, 18, 1) 30 | ); 31 | 32 | --background-footer: linear-gradient(180deg, #0b001b, #4f006e); 33 | 34 | /* transparent colors */ 35 | --main-1-trans: #0c142a88; 36 | --main-2-trans: #ffffff22; 37 | --main-3-trans: #ffffffbb; 38 | --main-accent-trans: #d300ff77; 39 | 40 | /*font colors*/ 41 | --font-main: #ffffff; 42 | --font-main-secondary: #0c142a; 43 | --font-main-white: #ffffff; 44 | --font-main-dark: #0c142a; 45 | 46 | /* others */ 47 | --spotify_main: #1db954; 48 | --main-red: #dd1122; 49 | } 50 | 51 | html[dark] { 52 | /*dark theme main colors*/ 53 | --main-1: #ffffff; 54 | --main-2: #0c142a; 55 | --main-3: ; 56 | --main-4: ; 57 | --main-5: ; 58 | 59 | /*font colors*/ 60 | --font-main: #1e3264; 61 | --font-main-white: #ffffff; 62 | --font-main-dark: #0c142a; 63 | 64 | /* others */ 65 | --spotify_main: #1db954; 66 | } 67 | -------------------------------------------------------------------------------- /src/views/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | background-color: var(--main-background); 3 | } 4 | 5 | .disclaimer { 6 | font-size: 12px; 7 | margin-bottom: 15px; 8 | z-index: 20; 9 | } 10 | 11 | .logo { 12 | position: absolute; 13 | left: 50px; 14 | top: 50px; 15 | } 16 | 17 | .logo:hover { 18 | cursor: pointer; 19 | } 20 | 21 | .bold { 22 | font-weight: 600; 23 | } 24 | 25 | .title { 26 | z-index: 200; 27 | } 28 | 29 | .no-scroll { 30 | height: 100vh; 31 | overflow: hidden; 32 | } 33 | 34 | .opaque { 35 | color: var(--main-accent); 36 | letter-spacing: 5px; 37 | } 38 | 39 | .opaque-text { 40 | color: rgba(255, 255, 255, 0.5); 41 | } 42 | 43 | .flex { 44 | display: flex; 45 | } 46 | 47 | .button-primary { 48 | background: var(--main-accent-gradient); 49 | text-shadow: black 1px 1px 10px; 50 | color: var(--font-main-white); 51 | font-weight: bold; 52 | } 53 | 54 | .button-secondary { 55 | border: 1px solid var(--main-2-trans); 56 | color: var(--main-3-trans); 57 | background-color: var(--main-2-trans); 58 | } 59 | 60 | .paragraph { 61 | margin: 20px 0; 62 | max-width: 600px; 63 | color: var(--main-3-trans); 64 | line-height: 30px; 65 | z-index: 20; 66 | } 67 | 68 | .paragraph-important { 69 | margin: 20px 0; 70 | max-width: 600px; 71 | color: var(--main-3-trans); 72 | line-height: 30px; 73 | z-index: 20; 74 | font-size: 20px; 75 | font-weight: bold; 76 | } 77 | 78 | .toast-element { 79 | position: absolute; 80 | right: 25px; 81 | top: 80px; 82 | background-color: var(--main-2-trans); 83 | border: 1px solid var(--main-accent); 84 | padding: 10px; 85 | border-radius: 8px; 86 | height: fit-content; 87 | width: fit-content; 88 | } 89 | -------------------------------------------------------------------------------- /src/views/common/footer/_style.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | padding: 20px; 6 | box-sizing: border-box; 7 | background: var(--background-footer); 8 | } 9 | 10 | .creators { 11 | display: flex; 12 | flex-direction: row; 13 | flex-wrap: wrap; 14 | justify-content: center; 15 | box-sizing: border-box; 16 | } 17 | 18 | .disclaimer { 19 | display: flex; 20 | flex-direction: column; 21 | justify-content: center; 22 | } 23 | 24 | .separator { 25 | margin: 25px; 26 | height: 1px; 27 | background-color: rgba(255, 255, 255, 0.8); 28 | width: 300px; 29 | } 30 | 31 | .separator-short { 32 | margin: 5px; 33 | height: 1px; 34 | background-color: rgba(255, 255, 255, 0.2); 35 | width: 100px; 36 | } 37 | 38 | .footer-link { 39 | text-decoration: none; 40 | } 41 | 42 | .creator-area { 43 | display: flex; 44 | flex-direction: column; 45 | align-items: center; 46 | margin: 0 30px 30px 30px; 47 | } 48 | 49 | .creator-info { 50 | display: flex; 51 | flex-direction: column; 52 | justify-content: center; 53 | align-items: center; 54 | margin-bottom: 25px; 55 | } 56 | 57 | .creator-link { 58 | display: flex; 59 | flex-direction: row; 60 | justify-content: center; 61 | border-radius: 10px; 62 | align-items: center; 63 | padding: 5px; 64 | margin-bottom: 5px; 65 | } 66 | 67 | .creator-link:hover { 68 | background-color: rgba(255, 255, 255, 0.2); 69 | } 70 | 71 | .creator-link-label { 72 | margin: 0; 73 | padding: 0; 74 | } 75 | 76 | .creator-image { 77 | height: 120px; 78 | margin-bottom: 10px; 79 | border-radius: 100%; 80 | } 81 | 82 | .creator-link-image { 83 | height: 30px; 84 | margin-right: 10px; 85 | } 86 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | overflow-y: scroll; 3 | overflow-x: hidden; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | font-family: 'Poppins', 'Roboto', 'Oxygen', 'Ubuntu', -apple-system, BlinkMacSystemFont, 9 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | background: black; 13 | min-height: 100vh; 14 | overflow-x: hidden; 15 | } 16 | 17 | button { 18 | height: 50px; 19 | width: fit-content; 20 | padding: 10px 20px; 21 | box-sizing: border-box; 22 | border: none; 23 | border-radius: 8px; 24 | margin: 20px 20px 0 0; 25 | z-index: 20; 26 | transition: all 0.5s; 27 | } 28 | 29 | button:disabled { 30 | cursor: not-allowed; 31 | filter: grayscale(1); 32 | } 33 | 34 | input { 35 | height: 50px; 36 | max-width: 500px; 37 | border-radius: 8px; 38 | border: 1px solid var(--main-2-trans); 39 | color: white; 40 | background-color: var(--main-2-trans); 41 | margin: 10px 0 30px 0; 42 | padding: 20px; 43 | box-sizing: border-box; 44 | } 45 | 46 | textarea { 47 | max-width: 500px; 48 | border-radius: 8px; 49 | border: 1px solid var(--main-2-trans); 50 | color: white; 51 | background-color: var(--main-2-trans); 52 | margin: 10px 0 30px 0; 53 | padding: 12px 20px; 54 | min-height: 100px; 55 | box-sizing: border-box; 56 | font-family: 'Poppins', 'Roboto', 'Oxygen', 'Ubuntu', -apple-system, BlinkMacSystemFont, 57 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 58 | } 59 | 60 | /* button:hover { 61 | box-shadow: #8e8d8d 2px 2px 8px 0px; 62 | } */ 63 | 64 | img { 65 | height: 100%; 66 | width: auto; 67 | } 68 | 69 | p { 70 | line-height: 20px; 71 | letter-spacing: 0.5px; 72 | } 73 | -------------------------------------------------------------------------------- /src/assets/statfy_logo_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/assets/statfy_logo_pink.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/assets/statfy_logo_purple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/views/common/defaultscreens/breakpointsConfig.js: -------------------------------------------------------------------------------- 1 | export default { 2 | breakpoints: [ 3 | { 4 | name: 'smallAndBelow', 5 | breakpoint: '(max-width: 599px)', 6 | }, 7 | { 8 | name: 'smallAndAbove', 9 | breakpoint: '(min-width: 600px)', 10 | }, 11 | { 12 | name: '600AndBelow', 13 | breakpoint: '(max-width: 599px)', 14 | }, 15 | { 16 | name: '600AndAbove', 17 | breakpoint: '(min-width: 600px)', 18 | }, 19 | { 20 | name: '700AndBelow', 21 | breakpoint: '(max-width: 699px)', 22 | }, 23 | { 24 | name: '700AndAbove', 25 | breakpoint: '(min-width: 700px)', 26 | }, 27 | { 28 | name: '800AndBelow', 29 | breakpoint: '(max-width: 799px)', 30 | }, 31 | { 32 | name: '800AndAbove', 33 | breakpoint: '(min-width: 800px)', 34 | }, 35 | { 36 | name: '900AndBelow', 37 | breakpoint: '(max-width: 899px)', 38 | }, 39 | { 40 | name: '900AndAbove', 41 | breakpoint: '(min-width: 900px)', 42 | }, 43 | { 44 | name: '1000AndBelow', 45 | breakpoint: '(max-width: 999px)', 46 | }, 47 | { 48 | name: '1000AndAbove', 49 | breakpoint: '(min-width: 1000px)', 50 | }, 51 | { 52 | name: 'mobileAndBelow', 53 | breakpoint: '(max-width: 899px)', 54 | }, 55 | { 56 | name: 'desktopAndAbove', 57 | breakpoint: '(min-width: 900px)', 58 | }, 59 | { 60 | name: '1200AndBelow', 61 | breakpoint: '(max-width: 1199px)', 62 | }, 63 | { 64 | name: '1200AndAbove', 65 | breakpoint: '(min-width: 1200px)', 66 | }, 67 | { 68 | name: 'largeAndBelow', 69 | breakpoint: '(max-width: 80099px)', 70 | }, 71 | { 72 | name: 'largeAndAbove', 73 | breakpoint: '(min-width: 1400px)', 74 | }, 75 | ], 76 | default: 'desktopAndAbove', 77 | }; 78 | -------------------------------------------------------------------------------- /src/views/common/user-badge/style.css: -------------------------------------------------------------------------------- 1 | .user_badge { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: center; 5 | align-items: center; 6 | z-index: 200; 7 | } 8 | 9 | .user_image { 10 | height: 40px; 11 | width: 40px; 12 | border-radius: 50%; 13 | border: 3px solid var(--font-main-white); 14 | box-sizing: border-box; 15 | } 16 | 17 | .user_image_mobile { 18 | height: 40px; 19 | width: 40px; 20 | box-sizing: border-box; 21 | } 22 | 23 | .user_information { 24 | display: flex; 25 | flex-direction: column; 26 | justify-content: flex-start; 27 | margin-left: 5px; 28 | } 29 | 30 | .user_name { 31 | margin: 0; 32 | font-weight: 600; 33 | } 34 | 35 | .logout_button { 36 | margin: 0; 37 | cursor: pointer; 38 | font-size: 14px; 39 | } 40 | 41 | .logout_button:hover { 42 | color: var(--main-accent); 43 | } 44 | 45 | @media only screen and (max-width: 1000px) { 46 | .fullscreen-menu { 47 | position: fixed; 48 | height: 100vh; 49 | width: 100vw; 50 | top: 0; 51 | left: 0; 52 | background: var(--main-background); 53 | display: flex; 54 | flex-direction: column; 55 | justify-content: center; 56 | align-items: center; 57 | visibility: hidden; 58 | z-index: 199; 59 | transform: scaleY(0); 60 | -webkit-transition: -webkit-transform 0.4s; 61 | transition: -webkit-transform 0.4s; 62 | transition: transform 0.4s; 63 | transition: transform 0.4s, -webkit-transform 0.4s; 64 | transform-origin: top; 65 | } 66 | 67 | .fullscreen-navigation-item { 68 | text-decoration: none; 69 | font-size: 32px; 70 | margin: 15px; 71 | font-weight: 200; 72 | color: var(--font-main-white); 73 | } 74 | 75 | .fullscreen-navigation-logout { 76 | font-size: 20px; 77 | } 78 | 79 | .fullscreen-navigation-active { 80 | font-weight: 600; 81 | } 82 | 83 | .menu-active { 84 | visibility: visible; 85 | z-index: 199; 86 | transform: scaleY(1); 87 | } 88 | 89 | .close-menu { 90 | height: 30px; 91 | width: fit-content; 92 | margin: 15px; 93 | padding: 10px; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/views/spotify/artists/Artists.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { fetchArtists } from '../../../services/spotifyservice'; 3 | import { Artist, DefaultErrorMessage } from '../../common'; 4 | import './style.css'; 5 | 6 | import { Spinner } from '../../common'; 7 | import useDataHook from '../../../hooks/useDataHook'; 8 | 9 | function Artists() { 10 | const [timerange, setTimerange] = useState('medium_term'); 11 | const [artistsRequest, setArtistsRequest] = useState(() => () => fetchArtists(timerange)); 12 | const { data: artists, isLoading, hasError } = useDataHook(artistsRequest); 13 | 14 | useEffect(() => { 15 | setArtistsRequest(() => () => fetchArtists(timerange)); 16 | }, [timerange]); 17 | 18 | if (hasError) return ; 19 | if (!artists > 0 && isLoading !== false) return ; 20 | 21 | const renderArtists = () => { 22 | return artists.map((artist, index) => { 23 | return Artist(artist, index); 24 | }); 25 | }; 26 | 27 | return ( 28 |
29 |

Favourite Artists

30 |
31 |
{ 33 | setTimerange('short_term'); 34 | }} 35 | className={`time-button ${timerange === 'short_term' ? 'button-selected' : ''}`} 36 | > 37 | 1 month 38 |
39 |
{ 41 | setTimerange('medium_term'); 42 | }} 43 | className={`time-button ${timerange === 'medium_term' ? 'button-selected' : ''}`} 44 | > 45 | 6 months 46 |
47 |
{ 49 | setTimerange('long_term'); 50 | }} 51 | className={`time-button ${timerange === 'long_term' ? 'button-selected' : ''}`} 52 | > 53 | all time 54 |
55 |
56 |
{renderArtists()}
57 |
58 | ); 59 | } 60 | 61 | export default Artists; 62 | -------------------------------------------------------------------------------- /src/views/AppRouter.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React, { createContext, useEffect, useState } from 'react'; 3 | import { Route } from 'react-router-dom'; 4 | 5 | import { Footer, Header } from './common'; 6 | import Landingpage from './landingpage/Landingpage'; 7 | import Analyze from './spotify/analyze/Analyze'; 8 | import Artists from './spotify/artists/Artists'; 9 | import Genres from './spotify/genres/Genres'; 10 | import Overview from './spotify/overview/Overview'; 11 | import Suggestions from './spotify/suggestions/Suggestions'; 12 | import Tracks from './spotify/tracks/Tracks'; 13 | 14 | import './App.css'; 15 | 16 | import { validateToken } from '../helper/authenticationhelper'; 17 | import { getData } from '../services/fetchservice'; 18 | import User from './user/User'; 19 | 20 | export const UserContext = createContext(); 21 | 22 | const AppRouter = ({ isLoading }) => { 23 | const [profile, setProfile] = useState(); 24 | 25 | useEffect(() => { 26 | const fetchUser = async () => { 27 | setProfile(await getData('me')); 28 | }; 29 | fetchUser(); 30 | }, []); 31 | 32 | if (isLoading || !profile) { 33 | return null; 34 | } 35 | 36 | if (!validateToken()) { 37 | return ; 38 | } 39 | 40 | return ( 41 | 42 |
43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | AppRouter.propTypes = { 58 | isLoading: PropTypes.bool.isRequired, 59 | }; 60 | 61 | AppRouter.defaultProps = { 62 | isLoading: false, 63 | }; 64 | 65 | export default AppRouter; 66 | -------------------------------------------------------------------------------- /src/views/common/footer/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import creatorItems from './creator-items'; 4 | 5 | import './_style.css'; 6 | 7 | const Footer = () => { 8 | const mapCreators = creators => { 9 | return creators.map((creator, index) => { 10 | return ( 11 |
12 |
13 | {creator.name} 14 |

{creator.name}

15 |
16 | {creator.links.map((link, i) => { 17 | return ( 18 | 19 |
20 | {`${creator.name} 25 |

{link.name}

26 |
27 |
28 | ); 29 | })} 30 |
31 | ); 32 | }); 33 | }; 34 | 35 | return ( 36 |
37 |

STATFY

38 |
39 |

PERSONALIZED STATISTICS

40 | {/*
41 |

42 | This app was developed as part of a school project. The developers do not have any rights 43 | on the trademarks shown on the page. 44 |

45 |
*/} 46 |
47 |
{mapCreators(creatorItems)}
48 | 49 |
50 |

CONTACT

51 |

52 | If you have questions or suggestions on how we could make our app more user friendly or if 53 | you want to request a feature, feel free to contact us! 54 |

55 | dev.statify@gmail.com 56 |
57 | ); 58 | }; 59 | 60 | export default Footer; 61 | -------------------------------------------------------------------------------- /src/views/roadmap/style.css: -------------------------------------------------------------------------------- 1 | .roadmap-area { 2 | width: 100vw; 3 | min-height: 100vh; 4 | position: relative; 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: flex-start; 8 | padding: 120px 50px 50px 50px; 9 | box-sizing: border-box; 10 | background: none; 11 | overflow: hidden; 12 | } 13 | 14 | .roadmap { 15 | margin-top: 80px; 16 | box-sizing: border-box; 17 | position: relative; 18 | display: flex; 19 | overflow-x: auto; 20 | height: 400px; 21 | width: calc(100vw - 100px); 22 | /* width: 100vw; */ 23 | } 24 | 25 | .roadmap-button { 26 | margin-top: 40px; 27 | } 28 | 29 | .roadmap-element { 30 | height: 280px; 31 | width: 250px; 32 | display: flex; 33 | flex-direction: column; 34 | justify-content: flex-start; 35 | align-items: center; 36 | } 37 | 38 | .roadmap-element-title { 39 | font-weight: 600; 40 | font-size: 20px; 41 | } 42 | 43 | .roadmap-element-date { 44 | font-weight: 600; 45 | font-size: 20px; 46 | } 47 | 48 | .roadmap-element-paragraph { 49 | text-align: center; 50 | padding: 15px; 51 | color: var(--main-3-trans); 52 | line-height: 30px; 53 | } 54 | 55 | .roadmap-progress { 56 | display: flex; 57 | justify-content: center; 58 | align-items: center; 59 | } 60 | 61 | .roadmap-divider { 62 | height: 4px; 63 | width: 220px; 64 | background-color: var(--main-3-trans); 65 | margin: 20px 0; 66 | } 67 | 68 | .roadmap-state { 69 | height: 30px; 70 | width: 30px; 71 | border-radius: 15px; 72 | box-sizing: border-box; 73 | } 74 | 75 | .margin-right-100 { 76 | margin-right: 100px; 77 | } 78 | 79 | .line-trough { 80 | text-decoration: line-through; 81 | } 82 | 83 | .text-inactive { 84 | color: var(--main-2-trans); 85 | } 86 | 87 | .inactive { 88 | background-color: var(--main-2-trans); 89 | } 90 | 91 | .roadmap-state-completed { 92 | background-color: var(--main-accent); 93 | } 94 | .roadmap-state-active { 95 | border: 3px solid var(--main-accent); 96 | } 97 | .roadmap-state-canceled { 98 | border: 3px solid var(--main-3-trans); 99 | } 100 | .roadmap-state-inactive { 101 | border: 3px solid var(--main-2-trans); 102 | } 103 | @media only screen and (max-width: 1000px) { 104 | } 105 | -------------------------------------------------------------------------------- /src/assets/instagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 8 | 9 | 10 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/views/spotify/overview/Overview.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import cx from 'classnames'; 3 | 4 | import './style.css'; 5 | import { DefaultErrorMessage, Spinner, TopArtist, TopTrack } from '../../common'; 6 | import useDataHook from '../../../hooks/useDataHook'; 7 | import { fetchMyTopArtist, fetchMyTopTrack } from '../../../services/spotifyservice'; 8 | import rangeOptions from '../../common/top-track/range-options'; 9 | 10 | function Overview() { 11 | const [timerange, setTimerange] = useState('medium_term'); 12 | const [artistRequest, setArtistRequest] = useState(() => () => fetchMyTopArtist(timerange)); 13 | const [trackRequest, setTrackRequest] = useState(() => () => fetchMyTopTrack(timerange)); 14 | const { data: topArtist, isLoading: artistIsLoading, hasError: artistError } = useDataHook( 15 | artistRequest, 16 | ); 17 | const { data: topTrack, isLoading: trackisLoading, hasError: trackError } = useDataHook( 18 | trackRequest, 19 | ); 20 | 21 | const isLoading = artistIsLoading || trackisLoading; 22 | 23 | useEffect(() => { 24 | setArtistRequest(() => () => fetchMyTopArtist(timerange)); 25 | setTrackRequest(() => () => fetchMyTopTrack(timerange)); 26 | }, [timerange]); 27 | 28 | if (artistError || trackError) return ; 29 | if (!topArtist || !topTrack || isLoading) return ; 30 | 31 | const background = imgUrl => { 32 | return { 33 | backgroundImage: `url(${imgUrl})`, 34 | }; 35 | }; 36 | 37 | return ( 38 |
39 |

Let's start with your favourites

40 |
41 | {rangeOptions.map((option, idx) => ( 42 |
!isLoading && setTimerange(option.value)} 45 | className={cx( 46 | 'time-button', 47 | option.value === timerange ? 'button-selected' : '', 48 | isLoading && 'disabled', 49 | )} 50 | > 51 | {option.label} 52 |
53 | ))} 54 |
55 | 56 | 57 |
58 | ); 59 | } 60 | 61 | export default Overview; 62 | -------------------------------------------------------------------------------- /src/views/common/defaultscreens/HideShow.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import forEach from 'lodash/fp/forEach'; 4 | import find from 'lodash/fp/find'; 5 | 6 | import breakpointsConfig from './breakpointsConfig'; 7 | import breakpointValidation from './breakpointValidation'; 8 | 9 | /** 10 | * Descriptively hide or show children components, based on a breakpoint 11 | * Uses matchMedia 12 | * Client-side only 13 | */ 14 | class HideShow extends React.Component { 15 | constructor(props) { 16 | super(props); 17 | this.state = { 18 | visible: false, 19 | }; 20 | 21 | this.breakpoint = ''; 22 | 23 | forEach(bp => { 24 | const mediaQuery = find(['name', bp], breakpointsConfig.breakpoints).breakpoint; 25 | if (!this.breakpoint) { 26 | this.breakpoint = mediaQuery; 27 | } else { 28 | this.breakpoint += ` and ${mediaQuery}`; 29 | } 30 | }, props.breakpoint.split(' ')); 31 | 32 | this.mql = null; 33 | this.onMatch = mql => this.updateVisibility(mql); 34 | 35 | // Class name for div (if shown) 36 | this.className = props.className; 37 | } 38 | 39 | componentDidMount() { 40 | if (!window.matchMedia) { 41 | throw new Error( 42 | 'Window.matchMedia is not supported by your Browser. Please update your Browser!', 43 | ); 44 | } 45 | 46 | this.mql = window.matchMedia(this.breakpoint); 47 | this.mql.addListener(this.onMatch); 48 | this.onMatch(this.mql); 49 | } 50 | 51 | componentWillUnmount() { 52 | if (this.mql) { 53 | this.mql.removeListener(this.onMatch); 54 | } 55 | } 56 | 57 | updateVisibility(mql) { 58 | const breakpointActive = !!mql.matches; 59 | 60 | if (this.props.hide) { 61 | this.setState({ 62 | visible: !breakpointActive, 63 | }); 64 | } else { 65 | this.setState({ 66 | visible: breakpointActive, 67 | }); 68 | } 69 | } 70 | 71 | render() { 72 | return this.state.visible ? {this.props.children} : null; 73 | } 74 | } 75 | 76 | HideShow.propTypes = { 77 | hide: PropTypes.bool.isRequired, 78 | breakpoint: breakpointValidation, 79 | children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]), 80 | className: PropTypes.string, 81 | }; 82 | 83 | HideShow.defaultProps = { 84 | breakpoint: breakpointsConfig.default, 85 | }; 86 | 87 | export default HideShow; 88 | -------------------------------------------------------------------------------- /src/views/spotify/analyze/Analyze.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | import './style.css'; 3 | import { DefaultErrorMessage, Playlist, Spinner } from '../../common'; 4 | import { getAudioAnalysis } from '../../../helper/analysationhelper'; 5 | import { UserContext } from '../../AppRouter'; 6 | import { fetchPlaylists } from '../../../services/spotifyservice'; 7 | import useDataHook from '../../../hooks/useDataHook'; 8 | 9 | function Analyze() { 10 | const { profile } = useContext(UserContext); 11 | const [activePlaylist, setActivePlaylist] = useState(); 12 | const [analyse, setAnalyse] = useState(); 13 | 14 | const [playlistsRequest, setPlaylistsRequest] = useState(() => () => fetchPlaylists(profile)); 15 | const { data: playlists, isLoading, hasError } = useDataHook(playlistsRequest); 16 | 17 | useEffect(() => { 18 | setPlaylistsRequest(() => () => fetchPlaylists(profile)); 19 | }, [profile]); 20 | 21 | if (hasError) return ; 22 | if (!playlists && isLoading !== false) return ; 23 | 24 | const fetchAnalyse = async playlist_id => { 25 | if (!playlist_id) return null; 26 | let analyseResponse = await getAudioAnalysis(playlist_id); 27 | setAnalyse(analyseResponse); 28 | }; 29 | 30 | if (!playlists) return null; 31 | 32 | const closePlaylist = () => { 33 | setAnalyse(null); 34 | setActivePlaylist(null); 35 | toggleScroll(); 36 | }; 37 | 38 | const changePlaylist = id => { 39 | setActivePlaylist(id); 40 | setAnalyse(fetchAnalyse(id)); 41 | toggleScroll(); 42 | }; 43 | 44 | const toggleScroll = () => { 45 | if (document.body.classList.contains('no-scroll')) { 46 | document.body.classList.remove('no-scroll'); 47 | document.body.addEventListener( 48 | 'touchmove', 49 | function (event) { 50 | event.preventDefault(); 51 | event.stopPropagation(); 52 | }, 53 | false, 54 | ); 55 | } else { 56 | document.body.classList.add('no-scroll'); 57 | document.body.removeEventListener( 58 | 'touchmove', 59 | function (event) { 60 | event.preventDefault(); 61 | event.stopPropagation(); 62 | }, 63 | false, 64 | ); 65 | } 66 | }; 67 | 68 | const renderPlaylists = () => { 69 | return playlists.map(playlist => { 70 | return Playlist( 71 | playlist, 72 | activePlaylist, 73 | changePlaylist, 74 | activePlaylist === playlist.id ? analyse : null, 75 | closePlaylist, 76 | ); 77 | }); 78 | }; 79 | 80 | return ( 81 |
82 |

How funky are your playlists?

83 |
{renderPlaylists()}
84 |
85 | ); 86 | } 87 | 88 | export default Analyze; 89 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 27 | 28 | 37 | STATFY - Spotify Statistics 38 | 39 | 40 | 41 | 42 | 44 |
45 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/views/landingpage/style.css: -------------------------------------------------------------------------------- 1 | .landingpage { 2 | display: flex; 3 | flex-direction: row; 4 | background-size: cover; 5 | /* filter: blur(1px); */ 6 | background-color: var(--main-background); 7 | overflow: hidden; 8 | } 9 | 10 | .landingpage-image { 11 | filter: blur(1px) opacity(0.5); 12 | height: 100vh; 13 | width: 100vw; 14 | background-size: cover; 15 | position: absolute; 16 | z-index: 0; 17 | top: 0; 18 | left: 0; 19 | } 20 | 21 | .color-circle { 22 | background: var(--main-accent-gradient); 23 | box-shadow: 1px 4px 30px 0px var(--main-accent-trans); 24 | height: 500px; 25 | width: 500px; 26 | top: 20vh; 27 | left: 50vw; 28 | border-radius: 50%; 29 | position: fixed; 30 | animation: circle 5s infinite ease-in-out; 31 | z-index: 0; 32 | } 33 | 34 | .langing-page-tag-title { 35 | color: var(--main-accent); 36 | background: var(--main-accent-gradient); 37 | width: fit-content; 38 | background-clip: text; 39 | -webkit-text-fill-color: transparent; 40 | letter-spacing: 5px; 41 | z-index: 20; 42 | } 43 | 44 | .landing-page-title { 45 | margin-top: 10px; 46 | font-size: 64px; 47 | letter-spacing: 3px; 48 | font-weight: 800; 49 | z-index: 20; 50 | line-height: 60px; 51 | } 52 | 53 | .landing-page-title-span { 54 | color: transparent; 55 | -webkit-text-stroke: 1px white; 56 | } 57 | 58 | .scroll { 59 | overflow-y: scroll !important; 60 | } 61 | .maring-top { 62 | margin-top: 100px; 63 | height: fit-content !important; 64 | } 65 | 66 | .login-area { 67 | width: 100vw; 68 | overflow-y: scroll; 69 | height: 100vh; 70 | position: relative; 71 | display: flex; 72 | flex-direction: column; 73 | justify-content: center; 74 | padding: 50px; 75 | box-sizing: border-box; 76 | background: none; 77 | } 78 | 79 | .login-buttons { 80 | display: flex; 81 | flex-wrap: wrap; 82 | } 83 | 84 | .login-button-image { 85 | height: 50px; 86 | width: 50px; 87 | } 88 | .spotify-image { 89 | height: 47px; 90 | width: 47px; 91 | } 92 | 93 | .warning-pulsing { 94 | height: 50px; 95 | width: auto; 96 | margin: 20px 20px 0 0; 97 | cursor: pointer; 98 | } 99 | 100 | @keyframes circle { 101 | from { 102 | transform: scale(0.97); 103 | left: 50vw; 104 | } 105 | 106 | 50% { 107 | transform: scale(1); 108 | left: 51vw; 109 | } 110 | to { 111 | transform: scale(0.97); 112 | left: 50vw; 113 | } 114 | } 115 | 116 | @media only screen and (max-width: 1000px) { 117 | .landing-page-title { 118 | font-size: 40px; 119 | line-height: 40px; 120 | } 121 | } 122 | 123 | .warning-container { 124 | height: 100vh; 125 | overflow: hidden; 126 | width: 100vw; 127 | padding: 20px; 128 | z-index: 19999999; 129 | background-color: blue; 130 | box-sizing: border-box; 131 | position: absolute; 132 | } 133 | -------------------------------------------------------------------------------- /src/helper/analysationhelper.js: -------------------------------------------------------------------------------- 1 | import { getData } from '../services/fetchservice'; 2 | 3 | const getAudioAnalysis = async playlist_id => { 4 | const songs_ids = await getSongs(playlist_id); 5 | const songs_audio_features = await getSongFeatures(songs_ids); 6 | return formatData(songs_audio_features); 7 | }; 8 | 9 | const getSongs = async playlist_id => { 10 | const songs = await getData(`playlists/${playlist_id}/tracks`, null, '?field=items(id)'); 11 | return songs.items.map(song => song.track.id); 12 | }; 13 | 14 | const getSongFeatures = async ids => { 15 | const id_string = ids.reduce((prev, curr, i) => { 16 | return prev + curr + (i === ids.length - 1 ? '' : ','); 17 | }, ''); 18 | return await getData('audio-features', null, `?ids=${id_string}`).then( 19 | result => result.audio_features, 20 | ); 21 | }; 22 | 23 | const addData = (prev, curr) => { 24 | if (curr === null) { 25 | return prev; 26 | } 27 | prev.danceability += curr.danceability; 28 | prev.energy += curr.energy; 29 | prev.speechiness += curr.speechiness; 30 | prev.acousticness += curr.acousticness; 31 | prev.instrumentalness += curr.instrumentalness; 32 | prev.liveness += curr.liveness; 33 | prev.valence += curr.valence; 34 | prev.tempo += curr.tempo; 35 | prev.duration_ms += curr.duration_ms; 36 | return prev; 37 | }; 38 | 39 | const divideData = (prev, count) => { 40 | prev.danceability /= count; 41 | prev.energy /= count; 42 | prev.speechiness /= count; 43 | prev.acousticness /= count; 44 | prev.instrumentalness /= count; 45 | prev.liveness /= count; 46 | prev.valence /= count; 47 | prev.tempo /= count; 48 | prev.duration = prev.duration_ms / 1000 / count; 49 | prev.playlist_duration = prev.duration_ms / 1000; 50 | prev.playlist_length = count; 51 | prev.id = 'analysis'; 52 | return prev; 53 | }; 54 | 55 | const getPercentageandCrop = analysis => { 56 | const series = []; 57 | series.push({ name: 'Acousticness', value: Math.round((analysis.acousticness *= 100)) }); 58 | series.push({ name: 'Danceability', value: Math.round((analysis.danceability *= 100)) }); 59 | series.push({ name: 'Energy', value: Math.round((analysis.energy *= 100)) }); 60 | series.push({ name: 'Instrumentalness', value: Math.round((analysis.instrumentalness *= 100)) }); 61 | series.push({ name: 'Liveness', value: Math.round((analysis.liveness *= 100)) }); 62 | series.push({ name: 'Speechiness', value: Math.round((analysis.speechiness *= 100)) }); 63 | series.push({ name: 'Happiness', value: Math.round((analysis.valence *= 100)) }); 64 | // series.push(Math.round(analysis.tempo)); 65 | return series; 66 | }; 67 | 68 | const formatData = songs => { 69 | if (songs[0] === null) { 70 | return { 71 | empty: true, 72 | }; 73 | } 74 | const playlist_analysis = songs.reduce((prev, curr, i) => { 75 | return i === songs.length - 1 ? divideData(prev, songs.length) : addData(prev, curr); 76 | }); 77 | return getPercentageandCrop(playlist_analysis); 78 | }; 79 | 80 | export { getAudioAnalysis }; 81 | -------------------------------------------------------------------------------- /src/views/landingpage/Landingpage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import { stars } from '../../assets'; 4 | import { validateToken, signIn } from '../../helper/authenticationhelper'; 5 | import { ShowAt } from '../common'; 6 | import './style.css'; 7 | 8 | function Landingpage() { 9 | const history = useHistory(); 10 | const [isAuthenticated, setIsAuthenticated] = useState(false); 11 | 12 | useEffect(() => { 13 | const checkAuth = async () => { 14 | const isValid = await validateToken(); 15 | if (isValid) { 16 | setIsAuthenticated(true); 17 | history.push('/overview'); 18 | } 19 | }; 20 | checkAuth(); 21 | }, [history]); 22 | 23 | const handleLogin = async () => { 24 | try { 25 | await signIn(); 26 | } catch (error) { 27 | console.error('Error logging in:', error); 28 | } 29 | }; 30 | 31 | if (isAuthenticated) { 32 | return null; 33 | } 34 | 35 | return ( 36 | <> 37 |
38 |
39 |

STATFY

40 |
41 |

STATISTICS & FACTS

42 |

43 | SPOTIFY STATISTICS 44 |

45 | 46 |

47 | With our website you can see what your most listened artists and tracks are. You can 48 | also create playlists with your favourite tracks directly from Statfy! 49 |

50 |
51 |

52 | Important information: Due to high maintenance efforts, 53 | Statfy will cease operations at the end of the year. A replacement solution will be 54 | introduced here, as soon as it is ready! Stay tuned! 55 |

56 |
57 | 63 | 71 | {/* */} 79 |
80 |
81 |
82 |
83 | 84 | ); 85 | } 86 | 87 | export default Landingpage; 88 | -------------------------------------------------------------------------------- /src/views/common/artist/style.css: -------------------------------------------------------------------------------- 1 | .artist { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | width: 350px; 6 | margin: 25px; 7 | position: relative; 8 | box-sizing: border-box; 9 | background: var(--main-2-trans); 10 | border-radius: 20px; 11 | } 12 | 13 | .artist-card-image { 14 | position: relative; 15 | width: 350px; 16 | height: 325px; 17 | background-size: cover; 18 | border-radius: 15px 15px 0 0; 19 | filter: grayscale(1); 20 | } 21 | 22 | .artist-card-genres { 23 | display: flex; 24 | flex-direction: column; 25 | position: absolute; 26 | width: 100%; 27 | align-items: flex-end; 28 | justify-content: flex-end; 29 | bottom: 0; 30 | box-sizing: border-box; 31 | padding: 10px; 32 | } 33 | 34 | .artist-card-genres-background { 35 | height: 100%; 36 | width: 100%; 37 | background: linear-gradient(0deg, #000000ff, #00000033, #00000000); 38 | } 39 | 40 | .artist-card-genre-tag { 41 | height: fit-content; 42 | width: fit-content; 43 | padding: 2px 6px; 44 | border: 1px solid white; 45 | background-color: #ffffff55; 46 | border-radius: 20px; 47 | margin-top: 10px; 48 | } 49 | 50 | .artist-card-genre-tag p { 51 | width: fit-content; 52 | height: fit-content; 53 | margin: 0; 54 | font-size: 10px; 55 | } 56 | 57 | .artist-rank { 58 | position: absolute; 59 | top: calc(50% - 10px); 60 | font-size: 100px; 61 | font-weight: 400; 62 | opacity: 0; 63 | z-index: 3; 64 | -webkit-transition: -webkit-opacity 0.3s; 65 | transition: -webkit-opacity 0.3s; 66 | transition: opacity 0.3s; 67 | transition: opacity 0.3s, -webkit-opacity 0.3s; 68 | } 69 | 70 | .artist-about { 71 | text-decoration: none; 72 | position: absolute; 73 | bottom: 0; 74 | right: 0; 75 | font-size: 15px; 76 | margin: 10px; 77 | font-weight: 400; 78 | opacity: 0; 79 | z-index: 3; 80 | -webkit-transition: -webkit-opacity 0.3s; 81 | transition: -webkit-opacity 0.3s; 82 | transition: opacity 0.3s; 83 | transition: opacity 0.3s, -webkit-opacity 0.3s; 84 | } 85 | 86 | .artist-about-arrow { 87 | width: 15px; 88 | } 89 | 90 | .img-container { 91 | height: fit-content; 92 | position: relative; 93 | display: flex; 94 | flex-direction: column; 95 | align-items: center; 96 | margin-bottom: 25px; 97 | } 98 | 99 | .img-container::after { 100 | content: ''; 101 | position: absolute; 102 | width: 100%; 103 | height: 100%; 104 | z-index: 2; 105 | background-color: #000; 106 | border-radius: 15px 15px 0 0; 107 | -webkit-transition: -webkit-opacity 0.3s; 108 | transition: -webkit-opacity 0.3s; 109 | transition: opacity 0.3s; 110 | transition: opacity 0.3s, -webkit-opacity 0.3s; 111 | opacity: 0; 112 | } 113 | 114 | .img-container:hover::after { 115 | opacity: 0.6; 116 | } 117 | 118 | .img-container:hover .artist-rank { 119 | opacity: 0.8; 120 | } 121 | 122 | .img-container:hover .artist-about { 123 | opacity: 0.8; 124 | } 125 | 126 | .artist-card-name { 127 | font-size: 18px; 128 | margin-bottom: 25px; 129 | text-align: center; 130 | } 131 | 132 | @media only screen and (max-width: 1185px) { 133 | .artist { 134 | margin: 25px 25px 0 25px; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/views/spotify/genres/Genres.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { calcTopGenres, calcTopGenresIncludingArtists } from '../../../helper/genrehelper'; 3 | import useDataHook from '../../../hooks/useDataHook'; 4 | import { fetchArtists } from '../../../services/spotifyservice'; 5 | import { DefaultErrorMessage, Spinner } from '../../common'; 6 | import Genre from '../../common/genre/Genre'; 7 | import './style.css'; 8 | 9 | function Genres() { 10 | const [timerange, setTimerange] = useState('medium_term'); 11 | const [includeArtistRating, setIncludeArtistRating] = useState(false); 12 | const [artistsRequest, setArtistsRequest] = useState(() => () => fetchArtists(timerange)); 13 | const { data: artists, isLoading, hasError } = useDataHook(artistsRequest); 14 | const [topGenres, setTopGenres] = useState(); 15 | const [artistsIncluded, setArtistsIncluded] = useState(false); 16 | 17 | useEffect(() => { 18 | setArtistsRequest(() => () => fetchArtists(timerange)); 19 | }, [timerange]); 20 | 21 | useEffect(() => { 22 | if (artists) 23 | setTopGenres( 24 | includeArtistRating ? calcTopGenresIncludingArtists(artists) : calcTopGenres(artists), 25 | ); 26 | }, [artists, includeArtistRating]); 27 | 28 | const renderGenres = () => { 29 | return topGenres.map((genre, index) => Genre(genre, index)); 30 | }; 31 | 32 | if (hasError) return ; 33 | if (!artists > 0 && isLoading !== false) return ; 34 | 35 | return ( 36 |
37 |

Favourite Genres

38 |
39 |
{ 41 | setTimerange('short_term'); 42 | }} 43 | className={`time-button ${timerange === 'short_term' ? 'button-selected' : ''}`} 44 | > 45 | 1 month 46 |
47 |
{ 49 | setTimerange('medium_term'); 50 | }} 51 | className={`time-button ${timerange === 'medium_term' ? 'button-selected' : ''}`} 52 | > 53 | 6 months 54 |
55 |
{ 57 | setTimerange('long_term'); 58 | }} 59 | className={`time-button ${timerange === 'long_term' ? 'button-selected' : ''}`} 60 | > 61 | all time 62 |
63 |
64 |
65 |
66 |

Calculate based on artist ranking

67 |
{ 73 | setArtistsIncluded(!artistsIncluded); 74 | setIncludeArtistRating(!includeArtistRating); 75 | }} 76 | > 77 | {' '} 78 |
84 |
85 |
86 |
87 |
{topGenres && renderGenres()}
88 |
89 | ); 90 | } 91 | 92 | export default Genres; 93 | -------------------------------------------------------------------------------- /src/services/authService.js: -------------------------------------------------------------------------------- 1 | import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; 2 | import config from '../config'; 3 | 4 | const { protocol, hostname, port } = window.location; 5 | const origin = `${protocol}//${hostname}${port ? `:${port}` : ''}`; 6 | 7 | // Spotify OAuth 2.0 with PKCE configuration 8 | const oidcConfig = { 9 | authority: 'https://accounts.spotify.com', 10 | client_id: config.spotifyAuthparams.client_id, 11 | redirect_uri: `${origin}/callback`, 12 | response_type: 'code', 13 | scope: config.spotifyAuthparams.scope, 14 | post_logout_redirect_uri: origin, 15 | 16 | // PKCE settings 17 | response_mode: 'query', 18 | 19 | // Spotify-specific metadata 20 | metadata: { 21 | issuer: 'https://accounts.spotify.com', 22 | authorization_endpoint: 'https://accounts.spotify.com/authorize', 23 | token_endpoint: 'https://accounts.spotify.com/api/token', 24 | userinfo_endpoint: 'https://api.spotify.com/v1/me', 25 | }, 26 | 27 | // Storage 28 | userStore: new WebStorageStateStore({ store: window.localStorage }), 29 | 30 | // Additional settings 31 | automaticSilentRenew: false, 32 | loadUserInfo: false, 33 | 34 | // PKCE 35 | extraQueryParams: { 36 | show_dialog: config.spotifyAuthparams.show_dialog, 37 | }, 38 | }; 39 | 40 | // Create UserManager instance 41 | const userManager = new UserManager(oidcConfig); 42 | 43 | // Auth service methods 44 | export const authService = { 45 | // Sign in - redirects to Spotify 46 | signIn: async () => { 47 | try { 48 | await userManager.signinRedirect(); 49 | } catch (error) { 50 | console.error('Error during sign in:', error); 51 | throw error; 52 | } 53 | }, 54 | 55 | // Handle callback after redirect from Spotify 56 | signInCallback: async () => { 57 | try { 58 | const user = await userManager.signinRedirectCallback(); 59 | return user; 60 | } catch (error) { 61 | console.error('Error during sign in callback:', error); 62 | throw error; 63 | } 64 | }, 65 | 66 | // Sign out 67 | signOut: async () => { 68 | try { 69 | await userManager.removeUser(); 70 | window.localStorage.clear(); 71 | window.location.href = origin; 72 | } catch (error) { 73 | console.error('Error during sign out:', error); 74 | throw error; 75 | } 76 | }, 77 | 78 | // Get current user 79 | getUser: async () => { 80 | try { 81 | const user = await userManager.getUser(); 82 | return user; 83 | } catch (error) { 84 | console.error('Error getting user:', error); 85 | return null; 86 | } 87 | }, 88 | 89 | // Get access token 90 | getAccessToken: async () => { 91 | try { 92 | const user = await userManager.getUser(); 93 | return user?.access_token || null; 94 | } catch (error) { 95 | console.error('Error getting access token:', error); 96 | return null; 97 | } 98 | }, 99 | 100 | // Check if token is expired 101 | isTokenExpired: async () => { 102 | try { 103 | const user = await userManager.getUser(); 104 | if (!user) return true; 105 | 106 | const currentTime = Math.floor(Date.now() / 1000); 107 | return user.expires_at ? user.expires_at < currentTime : true; 108 | } catch (error) { 109 | console.error('Error checking token expiration:', error); 110 | return true; 111 | } 112 | }, 113 | }; 114 | 115 | export default authService; 116 | -------------------------------------------------------------------------------- /src/views/feedback/Feedback.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import './style.css'; 3 | 4 | import { useHistory } from 'react-router-dom/cjs/react-router-dom'; 5 | import { stars } from '../../assets'; 6 | import toastHook from '../../hooks/toastHook'; 7 | import saveFeedback from '../../services/firebaseService'; 8 | import { ShowAt } from '../common'; 9 | 10 | function Feedback() { 11 | const history = useHistory(); 12 | const [email, setEmail] = useState(''); 13 | const [feedback, setFeedback] = useState(''); 14 | const [disabled, setDisabled] = useState(false); 15 | 16 | const { addToast, toast } = toastHook(); 17 | 18 | const disableButton = () => { 19 | setDisabled(true); 20 | }; 21 | 22 | const enableButton = () => { 23 | setDisabled(false); 24 | }; 25 | 26 | return ( 27 |
28 |
29 | {toast &&
{toast}
} 30 |
31 |

{ 34 | window.location.replace('/'); 35 | }} 36 | > 37 | STATFY 38 |

39 |

40 | YOUR FEEDBACK 41 |

42 | 43 |

44 | What do we need to know? What do you want in the next version of Statfy? 45 |

46 |
47 | 48 | 51 | setEmail(e.target.value)} 53 | type={'text'} 54 | placeholder="Your email address..." 55 | > 56 | 57 | 63 | 64 |
65 | 88 | 96 |
97 |
98 |
99 | ); 100 | } 101 | 102 | export default Feedback; 103 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at dev.statify@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/views/roadmap/Roadmap.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import { stars } from '../../assets'; 4 | import { validateToken } from '../../helper/authenticationhelper'; 5 | import './style.css'; 6 | 7 | function Roadmap() { 8 | const history = useHistory(); 9 | 10 | if (validateToken()) { 11 | history.push('/overview'); 12 | } 13 | 14 | return ( 15 |
16 |
17 |
18 |

{ 21 | window.location.replace('/'); 22 | }} 23 | > 24 | STATFY 25 |

26 |

27 | DEVELOPMENT ROADMAP 28 |

29 |

30 | We want to provide you with information about our current plans for the development of 31 | features 32 |

33 | 34 |
35 |
36 |

Current stage

37 |
38 |
39 |
40 |
41 |

TODAY

42 |

43 | Statfy allows users to view their listening behaviours. Users can create playlists 44 | with their favorite songs over different periods of time 45 |

46 |
47 |
48 |

Major redesign

49 |
50 |
51 |
52 |
53 |

MAR 2022

54 |

55 | Statfy will recieve major a overhaul and redesign. 56 |

57 |
58 |
59 |

Playlist Stats

60 |
61 |
62 |
63 |
64 |

JUN 2022

65 |

66 | The statistics page will recieve a new look and new stats will become available 67 |

68 | Leave feedback 69 |
70 |
71 |

Phaseout

72 |
73 |
74 |
75 |
76 |

DEZ 2024

77 |

78 | Due to high maintenance efforts, Statfy will cease operations at the end of the year. 79 |

80 |
81 |
82 | 90 |
91 |
92 | ); 93 | } 94 | 95 | export default Roadmap; 96 | -------------------------------------------------------------------------------- /src/views/common/top-artist/style.css: -------------------------------------------------------------------------------- 1 | .overlay { 2 | position: absolute; 3 | top: 50%; 4 | left: 50%; 5 | transform: translate(-50%, -50%); 6 | z-index: 1000; 7 | } 8 | 9 | .time-button.disabled { 10 | opacity: 0.8; 11 | } 12 | 13 | .time-button.disabled:hover { 14 | cursor: not-allowed; 15 | color: inherit; 16 | background: inherit; 17 | } 18 | 19 | .artist-top { 20 | position: relative; 21 | max-width: 900px; 22 | display: flex; 23 | flex-direction: row; 24 | background: var(--main-2-trans); 25 | margin: 30px 30px 60px 30px; 26 | border-radius: 20px; 27 | } 28 | 29 | .top-card-information { 30 | margin: 50px; 31 | display: flex; 32 | justify-content: center; 33 | flex-direction: column; 34 | max-width: 450px; 35 | } 36 | 37 | .top-card-description { 38 | width: fit-content; 39 | height: fit-content; 40 | margin-bottom: 20px; 41 | box-sizing: border-box; 42 | font-size: 24px; 43 | font-weight: 200; 44 | color: var(--font-main-white); 45 | } 46 | 47 | .top-card-primary { 48 | width: 450px; 49 | height: fit-content; 50 | box-sizing: border-box; 51 | font-size: 48px; 52 | line-height: 40px; 53 | color: var(--font-main-white); 54 | } 55 | 56 | .top-card-secondary { 57 | width: fit-content; 58 | height: fit-content; 59 | box-sizing: border-box; 60 | font-size: 24px; 61 | line-height: 24px; 62 | font-weight: 200; 63 | margin-top: 20px; 64 | color: var(--font-main-white); 65 | } 66 | 67 | .top-card-image { 68 | height: 400px; 69 | width: 400px; 70 | background-size: cover; 71 | background-position: center; 72 | filter: grayscale(1); 73 | } 74 | 75 | .top-card-image-artist { 76 | border-radius: 0px 15px 15px 0px; 77 | } 78 | 79 | .top-card-image-track { 80 | border-radius: 15px 0px 0px 15px; 81 | } 82 | 83 | .time-switch { 84 | display: flex; 85 | flex-direction: row; 86 | width: 300px; 87 | justify-content: space-between; 88 | margin-top: auto; 89 | align-self: center; 90 | } 91 | 92 | .time-switch-detail { 93 | align-self: center; 94 | } 95 | 96 | .time-button { 97 | height: fit-content; 98 | display: flex; 99 | justify-content: center; 100 | align-items: center; 101 | box-sizing: border-box; 102 | background: var(--main-2-trans); 103 | border-radius: 5px; 104 | color: var(--font-main); 105 | font-size: 16px; 106 | text-align: center; 107 | padding: 7px 10px; 108 | margin: 7px; 109 | } 110 | 111 | .button-selected { 112 | color: var(--font-main-white); 113 | text-shadow: black 1px 1px 5px; 114 | background: var(--main-accent-gradient); 115 | } 116 | 117 | .time-button:hover { 118 | color: var(--font-main-secondary); 119 | background: var(--main-2); 120 | cursor: pointer; 121 | } 122 | 123 | .padding-right { 124 | padding-right: 50px; 125 | } 126 | 127 | .padding-left { 128 | padding-left: 50px; 129 | } 130 | 131 | @media only screen and (max-width: 1000px) { 132 | .artist-top { 133 | flex-direction: column; 134 | align-items: center; 135 | margin: 25px 25px 0 25px; 136 | max-width: 350px; 137 | } 138 | 139 | .top-card-primary { 140 | width: fit-content; 141 | height: fit-content; 142 | margin-bottom: 40px; 143 | overflow-wrap: anywhere; 144 | } 145 | 146 | .top-card-information { 147 | min-height: 250px; 148 | align-items: center; 149 | max-width: 300px; 150 | margin: 25px; 151 | } 152 | 153 | .top-card-image { 154 | height: 325px; 155 | width: 350px; 156 | } 157 | 158 | .time-switch { 159 | width: 250px; 160 | } 161 | 162 | .top-card-information-artist { 163 | align-items: center; 164 | margin-bottom: 50px; 165 | } 166 | 167 | .top-card-information-track { 168 | align-items: center; 169 | margin-top: 50px; 170 | } 171 | 172 | .top-card-image-artist { 173 | border-radius: 0px 0px 15px 15px; 174 | } 175 | 176 | .top-card-image-track { 177 | border-radius: 15px 15px 0px 0px; 178 | } 179 | 180 | .padding-right { 181 | padding-right: 0px; 182 | } 183 | 184 | .padding-left { 185 | padding-left: 0px; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/views/spotify/tracks/Tracks.jsx: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import React, { useContext, useEffect, useState } from 'react'; 3 | import useDataHook from '../../../hooks/useDataHook'; 4 | import { getData, postData } from '../../../services/fetchservice'; 5 | import { fetchTracks } from '../../../services/spotifyservice'; 6 | import { UserContext } from '../../AppRouter'; 7 | import { DefaultErrorMessage, Spinner, Track } from '../../common'; 8 | import './style.css'; 9 | 10 | function Tracks() { 11 | const [showNotification, setShowNotification] = useState(); 12 | const [timerange, setTimerange] = useState('medium_term'); 13 | const [tracksRequest, setTracksRequest] = useState(() => () => fetchTracks(timerange)); 14 | const { data: tracks, isLoading, hasError } = useDataHook(tracksRequest); 15 | 16 | const { profile } = useContext(UserContext); 17 | 18 | useEffect(() => { 19 | setTracksRequest(() => () => fetchTracks(timerange)); 20 | }, [timerange]); 21 | 22 | if (hasError) return ; 23 | if (!tracks > 0 && isLoading !== false) return ; 24 | 25 | const renderTracks = () => { 26 | const filteredTacks = tracks.filter(track => track.name); 27 | return filteredTacks.map((track, index) => { 28 | return Track(track, index); 29 | }); 30 | }; 31 | 32 | const mapTrackUris = () => { 33 | return tracks.map(track => { 34 | return track.uri; 35 | }); 36 | }; 37 | 38 | const createPlaylist = async () => { 39 | const playlists = await getData('me/playlists'); 40 | const date = moment(new Date()).format('DD-MM-YYYY'); 41 | const timeRange = 42 | timerange === 'long_term' 43 | ? 'All time' 44 | : timerange === 'medium_term' 45 | ? 'Last 6 months' 46 | : 'Last month'; 47 | const playlistName = timeRange + ' favorites - ' + date; 48 | const filteredPlaylists = playlists.items.filter(playlist => playlist.name === playlistName); 49 | 50 | if (filteredPlaylists.length === 0) { 51 | const playlist = JSON.stringify({ 52 | name: playlistName, 53 | public: true, 54 | description: 'Generate your own playlist at https://statfy.xyz :)' 55 | }); 56 | const tracks = JSON.stringify({ 57 | uris: mapTrackUris(), 58 | }); 59 | 60 | const createdPlaylist = await postData(`users/${profile.id}/playlists`, playlist); 61 | const response = await postData(`playlists/${createdPlaylist.id}/tracks`, tracks); 62 | 63 | setShowNotification('done'); 64 | setTimeout(() => { 65 | setShowNotification('none'); 66 | }, 1000); 67 | 68 | return response; 69 | } 70 | setShowNotification('error'); 71 | setTimeout(() => { 72 | setShowNotification('none'); 73 | }, 1000); 74 | 75 | return false; 76 | }; 77 | 78 | return ( 79 |
80 |
{ 82 | createPlaylist(); 83 | }} 84 | className={`create-playlist-button ${ 85 | showNotification === 'done' || showNotification === 'error' ? 'hide' : '' 86 | }`} 87 | > 88 | Create Playlist 89 |
90 |
91 | Done 92 |
93 |
94 | Existing 95 |
96 | 97 |

Favourite Tracks

98 |
99 |
{ 101 | setTimerange('short_term'); 102 | }} 103 | className={`time-button ${timerange === 'short_term' ? 'button-selected' : ''}`} 104 | > 105 | 1 month 106 |
107 |
{ 109 | setTimerange('medium_term'); 110 | }} 111 | className={`time-button ${timerange === 'medium_term' ? 'button-selected' : ''}`} 112 | > 113 | 6 months 114 |
115 |
{ 117 | setTimerange('long_term'); 118 | }} 119 | className={`time-button ${timerange === 'long_term' ? 'button-selected' : ''}`} 120 | > 121 | all time 122 |
123 |
124 |
{renderTracks()}
125 |
126 | ); 127 | } 128 | 129 | export default Tracks; 130 | -------------------------------------------------------------------------------- /src/views/common/playlist/style.css: -------------------------------------------------------------------------------- 1 | .playlist-container { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | } 7 | .chart-area { 8 | display: flex; 9 | flex-direction: column; 10 | justify-content: center; 11 | align-items: flex-start; 12 | height: 100%; 13 | width: 50%; 14 | } 15 | 16 | .playlist-analyse { 17 | z-index: 1000; 18 | top: 0; 19 | left: 0; 20 | position: fixed; 21 | width: 100vw; 22 | height: 100vh; 23 | box-sizing: border-box; 24 | justify-content: space-between; 25 | padding: 20px; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | background-size: cover; 30 | background: var(--main-background); 31 | } 32 | 33 | .playlist-overlay { 34 | height: 60vh; 35 | width: 80vw; 36 | background: var(--main-2-trans); 37 | border-radius: 20px; 38 | box-sizing: border-box; 39 | padding: 50px; 40 | display: flex; 41 | flex-direction: row; 42 | align-items: center; 43 | } 44 | 45 | .analyse-property { 46 | display: flex; 47 | justify-content: flex-start; 48 | width: 50%; 49 | } 50 | 51 | .playlist { 52 | display: flex; 53 | flex-direction: column; 54 | align-items: center; 55 | width: 350px; 56 | margin: 25px; 57 | position: relative; 58 | box-sizing: border-box; 59 | background: var(--main-2-trans); 60 | border-radius: 20px; 61 | } 62 | 63 | .playlist-card-image { 64 | position: relative; 65 | width: 350px; 66 | height: 325px; 67 | background-size: cover; 68 | border-radius: 15px 15px 0 0; 69 | filter: grayscale(1); 70 | } 71 | 72 | .playlist-rank { 73 | position: absolute; 74 | top: calc(50% - 10px); 75 | cursor: pointer; 76 | font-size: 60px; 77 | font-weight: 400; 78 | opacity: 0; 79 | z-index: 3; 80 | -webkit-transition: -webkit-opacity 0.3s; 81 | transition: -webkit-opacity 0.3s; 82 | transition: opacity 0.3s; 83 | transition: opacity 0.3s, -webkit-opacity 0.3s; 84 | } 85 | 86 | .img-container { 87 | height: fit-content; 88 | position: relative; 89 | display: flex; 90 | flex-direction: column; 91 | align-items: center; 92 | margin-bottom: 25px; 93 | } 94 | 95 | .img-container:hover .playlist-rank { 96 | opacity: 0.8; 97 | } 98 | 99 | .playlist-card-name { 100 | font-size: 18px; 101 | margin-bottom: 25px; 102 | text-align: center; 103 | } 104 | 105 | @media only screen and (max-width: 1185px) { 106 | .playlist { 107 | margin: 25px 25px 0 25px; 108 | } 109 | .playlist-overlay { 110 | flex-direction: column; 111 | align-items: center; 112 | } 113 | .analyse-serie { 114 | display: flex; 115 | flex-direction: column; 116 | align-items: center; 117 | } 118 | .chart-area { 119 | width: 100% !important; 120 | height: 80%; 121 | } 122 | .analyse-chart { 123 | width: 100% !important; 124 | } 125 | .analyse-title-box { 126 | align-items: center; 127 | } 128 | } 129 | 130 | @media only screen and (max-width: 600px) { 131 | .playlist { 132 | margin: 25px 25px 0 25px; 133 | } 134 | .playlist-overlay { 135 | flex-direction: column; 136 | align-items: center; 137 | height: calc(100vh - 50px); 138 | width: calc(100vw - 50px); 139 | } 140 | .analyse-serie { 141 | display: flex; 142 | flex-direction: column; 143 | align-items: center; 144 | } 145 | .chart-area { 146 | width: 100% !important; 147 | height: 80%; 148 | } 149 | .analyse-chart { 150 | width: 100% !important; 151 | } 152 | .analyse-title-box { 153 | align-items: center; 154 | } 155 | .close-analyse { 156 | top: 50px !important; 157 | right: 50px !important; 158 | } 159 | .analyse-chart-box { 160 | width: 75vw !important; 161 | } 162 | } 163 | 164 | .analyse-title-box { 165 | display: flex; 166 | flex-direction: column; 167 | } 168 | 169 | .analyse-title { 170 | width: fit-content; 171 | } 172 | 173 | .analyse-name { 174 | font-weight: 600; 175 | font-size: 24px; 176 | width: fit-content; 177 | line-height: 30px; 178 | } 179 | 180 | .analyse-info { 181 | width: 50%; 182 | height: 100%; 183 | display: flex; 184 | flex-direction: column; 185 | justify-content: space-around; 186 | } 187 | 188 | .analyse-chart { 189 | position: relative; 190 | width: 50%; 191 | height: fit-content; 192 | } 193 | 194 | .analyse-chart-box { 195 | height: 10px; 196 | margin-bottom: 5px; 197 | width: 400px; 198 | display: flex; 199 | align-items: center; 200 | flex-direction: column; 201 | position: relative; 202 | } 203 | 204 | .analyse-chart-background { 205 | top: 0; 206 | position: absolute; 207 | border-radius: 5px; 208 | background-color: var(--main-2-trans); 209 | width: 100%; 210 | height: 10px; 211 | } 212 | 213 | .analyse-chart-foreground { 214 | top: 0; 215 | position: absolute; 216 | border-radius: 5px; 217 | background: var(--main-accent-gradient-analyse); 218 | height: 10px; 219 | align-self: flex-start; 220 | width: 0; 221 | transition-property: width; 222 | transition-duration: 0.3s; 223 | } 224 | 225 | .close-analyse { 226 | position: fixed; 227 | top: 30px; 228 | right: 30px; 229 | height: 30px; 230 | width: 30px; 231 | } 232 | -------------------------------------------------------------------------------- /src/views/common/user-badge/UserBadge.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useContext, useState } from 'react'; 2 | import { useHistory } from 'react-router-dom'; 3 | import { ShowAt } from '..'; 4 | import { close, menu_icon, user_icon } from '../../../assets'; 5 | import { clearToken } from '../../../helper/authenticationhelper'; 6 | import { UserContext } from '../../AppRouter'; 7 | 8 | import './style.css'; 9 | 10 | function Userbadge() { 11 | const history = useHistory(); 12 | const { profile } = useContext(UserContext); 13 | const [menuActive, setMenuActive] = useState(''); 14 | 15 | const logout = async () => { 16 | await clearToken(); 17 | history.push('/'); 18 | }; 19 | 20 | const toggleScroll = () => { 21 | if (document.body.classList.contains('no-scroll')) { 22 | document.body.classList.remove('no-scroll'); 23 | document.body.addEventListener( 24 | 'touchmove', 25 | function (event) { 26 | event.preventDefault(); 27 | event.stopPropagation(); 28 | }, 29 | false, 30 | ); 31 | } else { 32 | document.body.classList.add('no-scroll'); 33 | document.body.removeEventListener( 34 | 'touchmove', 35 | function (event) { 36 | event.preventDefault(); 37 | event.stopPropagation(); 38 | }, 39 | false, 40 | ); 41 | } 42 | }; 43 | 44 | const toggleMenu = () => { 45 | toggleScroll(); 46 | setMenuActive(menuActive === '' ? 'menu-active' : ''); 47 | }; 48 | 49 | return ( 50 | 51 | 52 |
toggleMenu()}> 53 | menu icon 64 |
65 |
66 | 74 | Overview 75 | 76 | 84 | Artists 85 | 86 | 94 | Tracks 95 | 96 | 104 | Playlists 105 | 106 | 114 | Genres 115 | 116 | 124 | Feedback 125 | 126 | close toggleMenu()} /> 127 |

logout()} 129 | className={`fullscreen-navigation-item fullscreen-navigation-logout`} 130 | > 131 | Logout 132 |

133 |
134 |
135 | 136 | 137 |
138 | {profile.display_name} 143 |
144 |

{profile.display_name}

145 |

logout()}> 146 | Logout 147 |

148 |
149 |
150 |
151 |
152 | ); 153 | } 154 | 155 | export default Userbadge; 156 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/), 19 | ); 20 | 21 | export function register(config) { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Let's check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl, config); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://bit.ly/CRA-PWA', 45 | ); 46 | }); 47 | } else { 48 | // Is not localhost. Just register service worker 49 | registerValidSW(swUrl, config); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl, config) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | if (installingWorker == null) { 62 | return; 63 | } 64 | installingWorker.onstatechange = () => { 65 | if (installingWorker.state === 'installed') { 66 | if (navigator.serviceWorker.controller) { 67 | // At this point, the updated precached content has been fetched, 68 | // but the previous service worker will still serve the older 69 | // content until all client tabs are closed. 70 | console.log( 71 | 'New content is available and will be used when all ' + 72 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.', 73 | ); 74 | 75 | // Execute callback 76 | if (config && config.onUpdate) { 77 | config.onUpdate(registration); 78 | } 79 | } else { 80 | // At this point, everything has been precached. 81 | // It's the perfect time to display a 82 | // "Content is cached for offline use." message. 83 | console.log('Content is cached for offline use.'); 84 | 85 | // Execute callback 86 | if (config && config.onSuccess) { 87 | config.onSuccess(registration); 88 | } 89 | } 90 | } 91 | }; 92 | }; 93 | }) 94 | .catch(error => { 95 | console.error('Error during service worker registration:', error); 96 | }); 97 | } 98 | 99 | function checkValidServiceWorker(swUrl, config) { 100 | // Check if the service worker can be found. If it can't reload the page. 101 | fetch(swUrl, { 102 | headers: { 'Service-Worker': 'script' }, 103 | }) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log('No internet connection found. App is running in offline mode.'); 124 | }); 125 | } 126 | 127 | export function unregister() { 128 | if ('serviceWorker' in navigator) { 129 | navigator.serviceWorker.ready.then(registration => { 130 | registration.unregister(); 131 | }); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/views/common/playlist/Playlist.jsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | 3 | import { close } from '../../../assets'; 4 | import './style.css'; 5 | 6 | function Playlist(playlist, activePlaylist, changePlaylist, analyse, closePlaylist) { 7 | let playlistImage = {}; 8 | // let chartoptions = { 9 | // tooltip: { 10 | // theme: 'dark', 11 | // }, 12 | // chart: { 13 | // height: 200, 14 | // toolbar: { 15 | // show: false, 16 | // }, 17 | // }, 18 | // noData: { 19 | // text: 'Loading...', 20 | // align: 'center', 21 | // verticalAlign: 'middle', 22 | // }, 23 | // stroke: { 24 | // curve: 'smooth', 25 | // }, 26 | // plotOptions: { 27 | // radar: { 28 | // size: 150, 29 | // offsetX: 0, 30 | // offsetY: 0, 31 | // polygons: { 32 | // strokeColors: '#FFFFFF', 33 | // strokeWidth: 2, 34 | // connectorColors: '#FFFFFF', 35 | // fill: { 36 | // colors: ['#FFFFFF00'], 37 | // }, 38 | // }, 39 | // }, 40 | // }, 41 | // yaxis: { 42 | // show: false, 43 | // labels: { 44 | // rotate: -90, 45 | // rotateAlways: true, 46 | // style: { 47 | // fontSize: '14px', 48 | // colors: '#FFFFFF', 49 | // }, 50 | // }, 51 | // }, 52 | // xaxis: { 53 | // labels: { 54 | // show: true, 55 | // style: { 56 | // fontSize: '14px', 57 | // colors: ['#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF', '#FFFFFF'], 58 | // }, 59 | // }, 60 | // categories: [ 61 | // 'Acousticness', 62 | // 'Danceability', 63 | // 'Energy', 64 | // 'Instrumentalness', 65 | // 'Liveness', 66 | // 'Speechiness', 67 | // 'Happiness', 68 | // ], 69 | // }, 70 | // }; 71 | 72 | if (playlist.images[0]) { 73 | playlistImage = { 74 | backgroundImage: `url(${playlist.images[0].url})`, 75 | }; 76 | } 77 | 78 | // const returnSeries = () => { 79 | // console.log(analyse); 80 | // if (analyse.length === 0) { 81 | // return null; 82 | // } 83 | // return [ 84 | // { 85 | // name: playlist.name, 86 | // data: analyse, 87 | // }, 88 | // ]; 89 | // }; 90 | 91 | const getWidth = width => { 92 | if (!width) { 93 | return { width: '0%', display: 'none' }; 94 | } 95 | return { width: `${width}%` }; 96 | }; 97 | 98 | const renderAnalysis = name => { 99 | return ( 100 |
101 |
102 |
103 |

About your playlist

104 |

{name}

105 |
106 | {/*

107 | The content of the playlist is analyzed by Spotify based on a couple of categories. The 108 | assessment includes the calculation of the proportion of vocal and instrumental parts of 109 | songs. Furthermore the average energy of each song is being calculated. Additionally 110 | Spotify gives insights into how euphoric or dystrophic the songs in your playlist are. 111 |

*/} 112 |
113 |
114 | {analyse.empty &&

This playlist appears to be empty

} 115 | {!analyse.empty && ( 116 | //
117 | // {analyse?.map(serie => { 118 | // return ( 119 | //
120 | //

{serie.name}

121 | //
122 | //
123 | //
127 | //
128 | //
129 | // ); 130 | // })} 131 | //
132 |
133 |
134 |

Acousticness

135 |
136 |
137 |
141 |
142 |
143 |
144 |

Danceability

145 |
146 |
147 |
151 |
152 |
153 |
154 |

Energy

155 |
156 |
157 |
161 |
162 |
163 |
164 |

Instrumentalness

165 |
166 |
167 |
171 |
172 |
173 |
174 |

Liveness

175 |
176 |
177 |
181 |
182 |
183 |
184 |

Speechiness

185 |
186 |
187 |
191 |
192 |
193 |
194 |

Happiness

195 |
196 |
197 |
201 |
202 |
203 |
204 | )} 205 |
206 | {/*
207 | {returnSeries().length === 0 && } 208 | {returnSeries().length > 0 && ( 209 | 216 | )} 217 |
*/} 218 |
219 | ); 220 | }; 221 | 222 | const renderOverlay = name => { 223 | return ( 224 |
225 | {renderAnalysis(name)} 226 | close closePlaylist()} className="close-analyse" /> 227 |
228 | ); 229 | }; 230 | 231 | return ( 232 |
233 |
234 |
{ 237 | changePlaylist(playlist.id); 238 | }} 239 | > 240 | {/* {artist.name} */} 241 |
242 |

Analyze

243 |
244 |

{playlist.name}

245 |
246 | {activePlaylist === playlist.id ? renderOverlay(playlist.name) : null} 247 |
248 | ); 249 | } 250 | 251 | export default Playlist; 252 | -------------------------------------------------------------------------------- /src/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0-modified | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | 95 | /* make sure to set some focus styles for accessibility */ 96 | :focus { 97 | outline: 0; 98 | } 99 | 100 | /* HTML5 display-role reset for older browsers */ 101 | article, 102 | aside, 103 | details, 104 | figcaption, 105 | figure, 106 | footer, 107 | header, 108 | hgroup, 109 | menu, 110 | nav, 111 | section { 112 | display: block; 113 | } 114 | 115 | body { 116 | line-height: 1; 117 | } 118 | 119 | ol, 120 | ul { 121 | list-style: none; 122 | } 123 | 124 | blockquote, 125 | q { 126 | quotes: none; 127 | } 128 | 129 | blockquote:before, 130 | blockquote:after, 131 | q:before, 132 | q:after { 133 | content: ''; 134 | content: none; 135 | } 136 | 137 | table { 138 | border-collapse: collapse; 139 | border-spacing: 0; 140 | } 141 | 142 | input[type='search']::-webkit-search-cancel-button, 143 | input[type='search']::-webkit-search-decoration, 144 | input[type='search']::-webkit-search-results-button, 145 | input[type='search']::-webkit-search-results-decoration { 146 | -webkit-appearance: none; 147 | -moz-appearance: none; 148 | } 149 | 150 | input[type='search'] { 151 | -webkit-appearance: none; 152 | -moz-appearance: none; 153 | -webkit-box-sizing: content-box; 154 | -moz-box-sizing: content-box; 155 | box-sizing: content-box; 156 | } 157 | 158 | textarea { 159 | overflow: auto; 160 | vertical-align: top; 161 | resize: vertical; 162 | } 163 | 164 | /** 165 | * Correct `inline-block` display not defined in IE 6/7/8/9 and Firefox 3. 166 | */ 167 | 168 | audio, 169 | canvas, 170 | video { 171 | display: inline-block; 172 | *display: inline; 173 | *zoom: 1; 174 | max-width: 100%; 175 | } 176 | 177 | /** 178 | * Prevent modern browsers from displaying `audio` without controls. 179 | * Remove excess height in iOS 5 devices. 180 | */ 181 | 182 | audio:not([controls]) { 183 | display: none; 184 | height: 0; 185 | } 186 | 187 | /** 188 | * Address styling not present in IE 7/8/9, Firefox 3, and Safari 4. 189 | * Known issue: no IE 6 support. 190 | */ 191 | 192 | [hidden] { 193 | display: none; 194 | } 195 | 196 | /** 197 | * 1. Correct text resizing oddly in IE 6/7 when body `font-size` is set using 198 | * `em` units. 199 | * 2. Prevent iOS text size adjust after orientation change, without disabling 200 | * user zoom. 201 | */ 202 | 203 | html { 204 | font-size: 100%; 205 | /* 1 */ 206 | -webkit-text-size-adjust: 100%; 207 | /* 2 */ 208 | -ms-text-size-adjust: 100%; 209 | /* 2 */ 210 | } 211 | 212 | /** 213 | * Address `outline` inconsistency between Chrome and other browsers. 214 | */ 215 | 216 | a:focus { 217 | outline: thin dotted; 218 | } 219 | 220 | /** 221 | * Improve readability when focused and also mouse hovered in all browsers. 222 | */ 223 | 224 | a:active, 225 | a:hover { 226 | outline: 0; 227 | } 228 | 229 | /** 230 | * 1. Remove border when inside `a` element in IE 6/7/8/9 and Firefox 3. 231 | * 2. Improve image quality when scaled in IE 7. 232 | */ 233 | 234 | img { 235 | border: 0; 236 | /* 1 */ 237 | -ms-interpolation-mode: bicubic; 238 | /* 2 */ 239 | } 240 | 241 | /** 242 | * Address margin not present in IE 6/7/8/9, Safari 5, and Opera 11. 243 | */ 244 | 245 | figure { 246 | margin: 0; 247 | } 248 | 249 | /** 250 | * Correct margin displayed oddly in IE 6/7. 251 | */ 252 | 253 | form { 254 | margin: 0; 255 | } 256 | 257 | /** 258 | * Define consistent border, margin, and padding. 259 | */ 260 | 261 | fieldset { 262 | border: 1px solid #c0c0c0; 263 | margin: 0 2px; 264 | padding: 0.35em 0.625em 0.75em; 265 | } 266 | 267 | /** 268 | * 1. Correct color not being inherited in IE 6/7/8/9. 269 | * 2. Correct text not wrapping in Firefox 3. 270 | * 3. Correct alignment displayed oddly in IE 6/7. 271 | */ 272 | 273 | legend { 274 | border: 0; 275 | /* 1 */ 276 | padding: 0; 277 | white-space: normal; 278 | /* 2 */ 279 | *margin-left: -7px; 280 | /* 3 */ 281 | } 282 | 283 | /** 284 | * 1. Correct font size not being inherited in all browsers. 285 | * 2. Address margins set differently in IE 6/7, Firefox 3+, Safari 5, 286 | * and Chrome. 287 | * 3. Improve appearance and consistency in all browsers. 288 | */ 289 | 290 | button, 291 | input, 292 | select, 293 | textarea { 294 | font-size: 100%; 295 | /* 1 */ 296 | margin: 0; 297 | /* 2 */ 298 | vertical-align: baseline; 299 | /* 3 */ 300 | *vertical-align: middle; 301 | /* 3 */ 302 | } 303 | 304 | /** 305 | * Address Firefox 3+ setting `line-height` on `input` using `!important` in 306 | * the UA stylesheet. 307 | */ 308 | 309 | button, 310 | input { 311 | line-height: normal; 312 | } 313 | 314 | /** 315 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 316 | * All other form control elements do not inherit `text-transform` values. 317 | * Correct `button` style inheritance in Chrome, Safari 5+, and IE 6+. 318 | * Correct `select` style inheritance in Firefox 4+ and Opera. 319 | */ 320 | 321 | button, 322 | select { 323 | text-transform: none; 324 | } 325 | 326 | /** 327 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 328 | * and `video` controls. 329 | * 2. Correct inability to style clickable `input` types in iOS. 330 | * 3. Improve usability and consistency of cursor style between image-type 331 | * `input` and others. 332 | * 4. Remove inner spacing in IE 7 without affecting normal text inputs. 333 | * Known issue: inner spacing remains in IE 6. 334 | */ 335 | 336 | button, 337 | html input[type="button"], 338 | /* 1 */ 339 | input[type="reset"], 340 | input[type="submit"] { 341 | -webkit-appearance: button; 342 | /* 2 */ 343 | cursor: pointer; 344 | /* 3 */ 345 | *overflow: visible; 346 | /* 4 */ 347 | } 348 | 349 | /** 350 | * Re-set default cursor for disabled elements. 351 | */ 352 | 353 | button[disabled], 354 | html input[disabled] { 355 | cursor: default; 356 | } 357 | 358 | /** 359 | * 1. Address box sizing set to content-box in IE 8/9. 360 | * 2. Remove excess padding in IE 8/9. 361 | * 3. Remove excess padding in IE 7. 362 | * Known issue: excess padding remains in IE 6. 363 | */ 364 | 365 | input[type='checkbox'], 366 | input[type='radio'] { 367 | box-sizing: border-box; 368 | /* 1 */ 369 | padding: 0; 370 | /* 2 */ 371 | *height: 13px; 372 | /* 3 */ 373 | *width: 13px; 374 | /* 3 */ 375 | } 376 | 377 | /** 378 | * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. 379 | * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome 380 | * (include `-moz` to future-proof). 381 | */ 382 | 383 | input[type='search'] { 384 | -webkit-appearance: textfield; 385 | /* 1 */ 386 | -moz-box-sizing: content-box; 387 | -webkit-box-sizing: content-box; 388 | /* 2 */ 389 | box-sizing: content-box; 390 | } 391 | 392 | /** 393 | * Remove inner padding and search cancel button in Safari 5 and Chrome 394 | * on OS X. 395 | */ 396 | 397 | input[type='search']::-webkit-search-cancel-button, 398 | input[type='search']::-webkit-search-decoration { 399 | -webkit-appearance: none; 400 | } 401 | 402 | /** 403 | * Remove inner padding and border in Firefox 3+. 404 | */ 405 | 406 | button::-moz-focus-inner, 407 | input::-moz-focus-inner { 408 | border: 0; 409 | padding: 0; 410 | } 411 | 412 | /** 413 | * 1. Remove default vertical scrollbar in IE 6/7/8/9. 414 | * 2. Improve readability and alignment in all browsers. 415 | */ 416 | 417 | textarea { 418 | overflow: auto; 419 | /* 1 */ 420 | vertical-align: top; 421 | /* 2 */ 422 | } 423 | 424 | /** 425 | * Remove most spacing between table cells. 426 | */ 427 | 428 | table { 429 | border-collapse: collapse; 430 | border-spacing: 0; 431 | } 432 | 433 | html, 434 | button, 435 | input, 436 | select, 437 | textarea { 438 | color: #222; 439 | } 440 | 441 | ::-moz-selection { 442 | background: #b3d4fc; 443 | text-shadow: none; 444 | } 445 | 446 | ::selection { 447 | background: #b3d4fc; 448 | text-shadow: none; 449 | } 450 | 451 | img { 452 | vertical-align: middle; 453 | } 454 | 455 | fieldset { 456 | border: 0; 457 | margin: 0; 458 | padding: 0; 459 | } 460 | 461 | textarea { 462 | resize: vertical; 463 | } 464 | 465 | .chromeframe { 466 | margin: 0.2em 0; 467 | background: #ccc; 468 | color: #000; 469 | padding: 0.2em 0; 470 | } 471 | --------------------------------------------------------------------------------