├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── images.d.ts
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── App.css
├── App.test.tsx
├── App.tsx
├── index.css
├── index.tsx
├── logo.svg
└── registerServiceWorker.ts
├── tsconfig.json
├── tsconfig.prod.json
├── tsconfig.test.json
├── tslint.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true
3 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Supportify
3 |
4 | https://tomduncalf.github.io/supportify/
5 |
6 | Support the artists you listen to on Spotify by buying their music on Bandcamp
7 |
8 | ### About
9 |
10 | Supportify is a simple app which uses the Spotify API to get your top artists/tracks, then links off to Bandcamp search pages for the track/artist (no Bandcamp API sadly).
11 |
12 | It's all done client side and no data is stored anywhere.
13 |
14 | Built with create-react-app, Typescript, Material UI and spotify-web-api-js
15 |
--------------------------------------------------------------------------------
/images.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg'
2 | declare module '*.png'
3 | declare module '*.jpg'
4 | declare module '*.jpeg'
5 | declare module '*.gif'
6 | declare module '*.bmp'
7 | declare module '*.tiff'
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "supportify",
3 | "version": "0.1.0",
4 | "homepage": "https://tomduncalf.github.io/supportify",
5 | "dependencies": {
6 | "@material-ui/core": "^3.6.1",
7 | "react": "^16.6.3",
8 | "react-dom": "^16.6.3",
9 | "react-scripts-ts": "3.1.0",
10 | "spotify-web-api-js": "^1.1.1"
11 | },
12 | "scripts": {
13 | "start": "react-scripts-ts start",
14 | "build": "react-scripts-ts build",
15 | "test": "react-scripts-ts test --env=jsdom",
16 | "eject": "react-scripts-ts eject",
17 | "predeploy": "npm run build",
18 | "deploy": "gh-pages -d build"
19 | },
20 | "devDependencies": {
21 | "@types/jest": "^23.3.10",
22 | "@types/node": "^10.12.12",
23 | "@types/react": "^16.7.13",
24 | "@types/react-dom": "^16.0.11",
25 | "gh-pages": "2.0.1",
26 | "prettier": "1.15.3",
27 | "typescript": "^3.2.2"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomduncalf/supportify/a032921079fc366b6f11e69ed26d021f6838c93d/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
14 |
15 |
16 |
17 |
21 |
22 |
31 |
32 | Supportify - Support the artists you listen to on Spotify by buying their
33 | music on Bandcamp
34 |
35 |
36 |
37 |
38 |
39 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | body {
2 | padding: 20px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | ReactDOM.unmountComponentAtNode(div);
9 | });
10 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Table,
3 | TableRow,
4 | TableBody,
5 | TableCell,
6 | Grid,
7 | Select,
8 | Button,
9 | FormControl,
10 | InputLabel,
11 | MenuItem
12 | } from "@material-ui/core";
13 | import * as React from "react";
14 | import SpotifyWebApi from "spotify-web-api-js";
15 |
16 | const initialState = {
17 | authenticated: false,
18 | limit: 20,
19 | timeRange: "medium_term",
20 | topArtists: undefined as SpotifyApi.UsersTopArtistsResponse | undefined,
21 | topTracks: undefined as SpotifyApi.UsersTopTracksResponse | undefined
22 | };
23 |
24 | class App extends React.Component<{}, typeof initialState> {
25 | state = { ...initialState };
26 | spotifyApi: SpotifyWebApi.SpotifyWebApiJs;
27 |
28 | componentDidMount() {
29 | if (window.location.hash) {
30 | window.location.hash
31 | .slice(1)
32 | .split("&")
33 | .forEach(kv => {
34 | const [key, value] = kv.split("=");
35 | if (key === "access_token") {
36 | this.setupSpotifyClient(value);
37 | this.getData();
38 | }
39 | });
40 | }
41 | }
42 |
43 | authenticate = () => {
44 | const callbackUrl = 'https://tomduncalf.github.io/supportify/';
45 |
46 | window.location.href =
47 | `https://accounts.spotify.com/authorize?client_id=af4c2b7672ec4460b8384790410d8658&redirect_uri=${encodeURIComponent(callbackUrl)}&scope=user-top-read&response_type=token`;
48 | };
49 |
50 | setupSpotifyClient = (accessToken: string) => {
51 | this.spotifyApi = new SpotifyWebApi();
52 | this.spotifyApi.setAccessToken(accessToken);
53 |
54 | this.setState({ authenticated: true });
55 | };
56 |
57 | getData = async () => {
58 | try {
59 | const [topArtists, topTracks] = await Promise.all([
60 | this.spotifyApi.getMyTopArtists({
61 | time_range: this.state.timeRange,
62 | limit: this.state.limit
63 | }),
64 | this.spotifyApi.getMyTopTracks({
65 | time_range: this.state.timeRange,
66 | limit: this.state.limit
67 | })
68 | ]);
69 |
70 | this.setState({ topArtists, topTracks });
71 | } catch (e) {
72 | console.error(e); //tslint:disable-line
73 | this.setState({ authenticated: false });
74 | }
75 | };
76 |
77 | searchBandcamp = (query: string) => {
78 | window.open("https://bandcamp.com/search?q=" + encodeURIComponent(query));
79 | };
80 |
81 | renderHeader = () => (
82 |
83 |
84 | Time range
85 |
97 |
98 |
99 |
100 | Items
101 |
114 |
115 |
116 | );
117 |
118 | handleTimeRangeChange = (timeRange: React.ChangeEvent) => {
119 | this.setState({ timeRange: timeRange.target.value }, () => this.getData());
120 | };
121 |
122 | handleLimitChange = (limit: React.ChangeEvent) => {
123 | this.setState({ limit: parseInt(limit.target.value, 10) }, () =>
124 | this.getData()
125 | );
126 | };
127 |
128 | renderTopArtists = () => {
129 | const { topArtists } = this.state;
130 | if (!topArtists) return null;
131 |
132 | return (
133 |
134 | Top Artists
135 |
136 |
137 | {topArtists.items.map(artist => (
138 |
139 | {artist.name}
140 |
141 |
148 |
149 |
150 | ))}
151 |
152 |
153 |
154 | );
155 | };
156 |
157 | renderTopTracks = () => {
158 | const { topTracks } = this.state;
159 | if (!topTracks) return null;
160 |
161 | return (
162 |
163 | Top Tracks
164 |
165 |
166 | {topTracks.items.map(track => {
167 | const artist = track.artists.map(a => a.name).join(", ");
168 | const name = `${artist} — ${track.name}`;
169 | const search = `"${track.name}" "${artist}"`;
170 |
171 | return (
172 |
173 | {name}
174 |
175 |
182 |
183 |
184 | );
185 | })}
186 |
187 |
188 |
189 | );
190 | };
191 |
192 | render() {
193 | const { authenticated } = this.state;
194 |
195 | return (
196 |
197 | Supportify
198 |
199 | Support the artists you listen to on Spotify by buying their music on
200 | Bandcamp
201 |
202 |
203 | {authenticated ? (
204 |
205 | {this.renderHeader()}
206 | {this.renderTopArtists()}
207 | {this.renderTopTracks()}
208 |
209 | ) : (
210 |
217 | )}
218 |
219 |
220 | Built by Tom Duncalf.
221 | Source code available on{" "}
222 | Github.
223 |
224 |
225 | );
226 | }
227 | }
228 |
229 | export default App;
230 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 20px;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as ReactDOM from 'react-dom';
3 | import App from './App';
4 | import './index.css';
5 | import registerServiceWorker from './registerServiceWorker';
6 |
7 | ReactDOM.render(
8 | ,
9 | document.getElementById('root') as HTMLElement
10 | );
11 | registerServiceWorker();
12 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.ts:
--------------------------------------------------------------------------------
1 | // tslint:disable:no-console
2 | // In production, we register a service worker to serve assets from local cache.
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 the 'N+1' visit to a page, since previously
7 | // cached resources are updated in the background.
8 |
9 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
10 | // This link also includes instructions on opting out of this behavior.
11 |
12 | const isLocalhost = Boolean(
13 | window.location.hostname === 'localhost' ||
14 | // [::1] is the IPv6 localhost address.
15 | window.location.hostname === '[::1]' ||
16 | // 127.0.0.1/8 is considered localhost for IPv4.
17 | window.location.hostname.match(
18 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
19 | )
20 | );
21 |
22 | export default function register() {
23 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
24 | // The URL constructor is available in all browsers that support SW.
25 | const publicUrl = new URL(
26 | process.env.PUBLIC_URL!,
27 | window.location.toString()
28 | );
29 | if (publicUrl.origin !== window.location.origin) {
30 | // Our service worker won't work if PUBLIC_URL is on a different origin
31 | // from what our page is served on. This might happen if a CDN is used to
32 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
33 | return;
34 | }
35 |
36 | window.addEventListener('load', () => {
37 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
38 |
39 | if (isLocalhost) {
40 | // This is running on localhost. Lets check if a service worker still exists or not.
41 | checkValidServiceWorker(swUrl);
42 |
43 | // Add some additional logging to localhost, pointing developers to the
44 | // service worker/PWA documentation.
45 | navigator.serviceWorker.ready.then(() => {
46 | console.log(
47 | 'This web app is being served cache-first by a service ' +
48 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
49 | );
50 | });
51 | } else {
52 | // Is not local host. Just register service worker
53 | registerValidSW(swUrl);
54 | }
55 | });
56 | }
57 | }
58 |
59 | function registerValidSW(swUrl: string) {
60 | navigator.serviceWorker
61 | .register(swUrl)
62 | .then(registration => {
63 | registration.onupdatefound = () => {
64 | const installingWorker = registration.installing;
65 | if (installingWorker) {
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the old content will have been purged and
70 | // the fresh content will have been added to the cache.
71 | // It's the perfect time to display a 'New content is
72 | // available; please refresh.' message in your web app.
73 | console.log('New content is available; please refresh.');
74 | } else {
75 | // At this point, everything has been precached.
76 | // It's the perfect time to display a
77 | // 'Content is cached for offline use.' message.
78 | console.log('Content is cached for offline use.');
79 | }
80 | }
81 | };
82 | }
83 | };
84 | })
85 | .catch(error => {
86 | console.error('Error during service worker registration:', error);
87 | });
88 | }
89 |
90 | function checkValidServiceWorker(swUrl: string) {
91 | // Check if the service worker can be found. If it can't reload the page.
92 | fetch(swUrl)
93 | .then(response => {
94 | // Ensure service worker exists, and that we really are getting a JS file.
95 | if (
96 | response.status === 404 ||
97 | response.headers.get('content-type')!.indexOf('javascript') === -1
98 | ) {
99 | // No service worker found. Probably a different app. Reload the page.
100 | navigator.serviceWorker.ready.then(registration => {
101 | registration.unregister().then(() => {
102 | window.location.reload();
103 | });
104 | });
105 | } else {
106 | // Service worker found. Proceed as normal.
107 | registerValidSW(swUrl);
108 | }
109 | })
110 | .catch(() => {
111 | console.log(
112 | 'No internet connection found. App is running in offline mode.'
113 | );
114 | });
115 | }
116 |
117 | export function unregister() {
118 | if ('serviceWorker' in navigator) {
119 | navigator.serviceWorker.ready.then(registration => {
120 | registration.unregister();
121 | });
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "outDir": "build/dist",
5 | "module": "esnext",
6 | "target": "es5",
7 | "lib": ["es6", "dom"],
8 | "sourceMap": true,
9 | "allowJs": true,
10 | "jsx": "react",
11 | "moduleResolution": "node",
12 | "rootDir": "src",
13 | "forceConsistentCasingInFileNames": true,
14 | "noImplicitReturns": true,
15 | "noImplicitThis": true,
16 | "noImplicitAny": true,
17 | "importHelpers": true,
18 | "strictNullChecks": true,
19 | "suppressImplicitAnyIndexErrors": true,
20 | "noUnusedLocals": true
21 | },
22 | "exclude": [
23 | "node_modules",
24 | "build",
25 | "scripts",
26 | "acceptance-tests",
27 | "webpack",
28 | "jest",
29 | "src/setupTests.ts"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/tsconfig.prod.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json"
3 | }
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs"
5 | }
6 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-react", "tslint-config-prettier"],
3 | "linterOptions": {
4 | "exclude": [
5 | "config/**/*.js",
6 | "node_modules/**/*.ts",
7 | "coverage/lcov-report/*.js"
8 | ]
9 | },
10 | "defaultSeverity": "warning",
11 | "rules": {
12 | "member-access": false,
13 | "ordered-imports": false,
14 | "jsx-no-lambda": false,
15 | "curly": false,
16 | "jsx-boolean-value": false
17 | }
18 | }
19 |
--------------------------------------------------------------------------------