├── .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 | 2 | 3 | 4 | 5 | 6 | 7 | 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 | --------------------------------------------------------------------------------