├── .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 |
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 |
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 |
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 |
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 | [](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 |
10 |
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 |
{
25 | window.location.replace('/');
26 | }}
27 | >
28 | Back to home
29 |
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 |
31 |
32 |
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 |
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 | You need to enable JavaScript to run this app.
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 |
61 | LOG IN WITH SPOTIFY
62 |
63 | {
66 | window.location.replace('/roadmap');
67 | }}
68 | >
69 | Development
70 |
71 | {/* {
74 | window.location.replace('/about');
75 | }}
76 | >
77 | Learn more
78 | */}
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 |
49 | Enter email if you want to be updated when the new version of Statfy is online.
50 |
51 |
setEmail(e.target.value)}
53 | type={'text'}
54 | placeholder="Your email address..."
55 | >
56 |
Let us know what you like and/or dislike about Statfy in its current state.
57 |
63 |
64 |
65 | {
69 | disableButton();
70 | try {
71 | await saveFeedback({ email, feedback, creation_date: new Date() });
72 | addToast('Thanks for your feedback!');
73 | setTimeout(() => {
74 | addToast(null);
75 | history.push('/');
76 | }, 3000);
77 | } catch {
78 | addToast('Something went wrong. Try again later.');
79 | enableButton();
80 | }
81 | setTimeout(() => {
82 | addToast(null);
83 | }, 3000);
84 | }}
85 | >
86 | Send feedback
87 |
88 | {
91 | window.location.replace('/');
92 | }}
93 | >
94 | Back to home
95 |
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 |
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 |
53 |
MAR 2022
54 |
55 | Statfy will recieve major a overhaul and redesign.
56 |
57 |
58 |
59 |
Playlist Stats
60 |
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 |
76 |
DEZ 2024
77 |
78 | Due to high maintenance efforts, Statfy will cease operations at the end of the year.
79 |
80 |
81 |
82 |
{
85 | window.location.replace('/');
86 | }}
87 | >
88 | Back to home
89 |
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 |
64 |
65 |
134 |
135 |
136 |
137 |
138 |
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 |
142 |
143 |
144 |
Danceability
145 |
152 |
153 |
163 |
164 |
Instrumentalness
165 |
172 |
173 |
174 |
Liveness
175 |
182 |
183 |
184 |
Speechiness
185 |
192 |
193 |
194 |
Happiness
195 |
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 |
closePlaylist()} className="close-analyse" />
227 |
228 | );
229 | };
230 |
231 | return (
232 |
233 |
234 |
{
237 | changePlaylist(playlist.id);
238 | }}
239 | >
240 | {/*
*/}
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 |
--------------------------------------------------------------------------------