├── .gitignore ├── README.md ├── client ├── node_modules │ └── .cache │ │ └── default-development │ │ ├── 0.pack │ │ └── index.pack ├── package.json ├── public │ ├── CleanifyLogo.png │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.jsx │ ├── assets │ │ └── CleanifyHomePageV2.png │ ├── components │ │ ├── Header.jsx │ │ ├── Modals │ │ │ ├── Conflict │ │ │ │ ├── ConflictAccordion.jsx │ │ │ │ ├── ConflictModal.jsx │ │ │ │ └── SongListItem.jsx │ │ │ ├── ExplainModal.jsx │ │ │ └── SummaryModal.jsx │ │ ├── ProgressBar.jsx │ │ └── Tables │ │ │ ├── CleanSongTable.jsx │ │ │ ├── CustomTable.jsx │ │ │ ├── PlaylistTable.jsx │ │ │ └── SongTable.jsx │ ├── contexts │ │ └── GlobalContext.jsx │ ├── hooks │ │ └── useAuth.jsx │ ├── index.jsx │ ├── pages │ │ ├── Home.jsx │ │ └── Login.jsx │ └── utils │ │ ├── Constants.jsx │ │ └── api.js └── yarn.lock ├── package-lock.json ├── server ├── app.js ├── package.json └── yarn.lock └── vercel.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .vercel 25 | 26 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cleanify 2 | 3 | ### What it does: 4 | 5 | Cleanify converts your Explicit spotify playlists into Clean spotify playlists so you can listen to your favorite playlists where ever you go! You can also convert a playlist to only have explicit songs. 6 | 7 | ### How it works: 8 | 9 | A request is made to Spotify's API to search each song in the playlist. Each search result is checked to see whether or not it is clean and if the names of the both songs are the same. If the potential clean song's name is similar to the original, but not exact, it will be marked as a conflict so the user can manually go through and decide whether or not the potentially clean song is the same as the original. 10 | 11 | ### How to run locally 12 | 13 | 1. Create .env file with corresponding env variables from Spotify's developer dashboard 14 | 2. Navigate to the client directory and run `yarn && yarn start` 15 | 3. Navigate to the server directory and run `yarn && node app.js` 16 | 4. Go to http://localhost:3000 17 | 18 | #### Credit 19 | 20 | Project Idea from [here](https://github.com/Divide-By-0/app-ideas-people-would-use) 21 | -------------------------------------------------------------------------------- /client/node_modules/.cache/default-development/0.pack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-arman/Cleanify/11a6a2a0f070b94a4fc1281c156a505267d8e820/client/node_modules/.cache/default-development/0.pack -------------------------------------------------------------------------------- /client/node_modules/.cache/default-development/index.pack: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-arman/Cleanify/11a6a2a0f070b94a4fc1281c156a505267d8e820/client/node_modules/.cache/default-development/index.pack -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@chakra-ui/icons": "^1.1.7", 7 | "@chakra-ui/react": "^1.8.6", 8 | "@emotion/react": "^11", 9 | "@emotion/styled": "^11", 10 | "@fortawesome/fontawesome-free": "^6.1.0", 11 | "@mui/icons-material": "^5.5.1", 12 | "@testing-library/jest-dom": "^5.14.1", 13 | "@testing-library/react": "^12.0.0", 14 | "@testing-library/user-event": "^13.2.1", 15 | "axios": "^0.26.0", 16 | "cookie-parser": "^1.4.6", 17 | "cors": "^2.8.5", 18 | "dotenv": "^16.0.0", 19 | "express-session": "^1.17.2", 20 | "framer-motion": "^6", 21 | "fuzzball": "^2.1.2", 22 | "js-cookie": "^3.0.1", 23 | "nodemon": "^2.0.15", 24 | "posthog-js": "^1.130.2", 25 | "react": "^17.0.2", 26 | "react-dom": "^17.0.2", 27 | "react-icons": "^4.3.1", 28 | "react-router-dom": "^6.2.2", 29 | "react-scripts": "5.0.0", 30 | "react-simple-oauth2-login": "^0.5.3", 31 | "react-spotify-api": "^3.0.0", 32 | "react-spotify-auth": "^1.4.3", 33 | "react-table": "^7.7.0", 34 | "request": "^2.88.2", 35 | "spotify-web-api-node": "^5.0.2", 36 | "vercel": "^24.0.0" 37 | }, 38 | "scripts": { 39 | "start": "react-scripts start", 40 | "build": "react-scripts build", 41 | "test": "react-scripts test", 42 | "eject": "react-scripts eject" 43 | }, 44 | "eslintConfig": { 45 | "extends": [ 46 | "react-app", 47 | "react-app/jest" 48 | ] 49 | }, 50 | "browserslist": { 51 | "production": [ 52 | ">0.2%", 53 | "not dead", 54 | "not op_mini all" 55 | ], 56 | "development": [ 57 | "last 1 chrome version", 58 | "last 1 firefox version", 59 | "last 1 safari version" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/public/CleanifyLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-arman/Cleanify/11a6a2a0f070b94a4fc1281c156a505267d8e820/client/public/CleanifyLogo.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-arman/Cleanify/11a6a2a0f070b94a4fc1281c156a505267d8e820/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 22 | 26 | 27 | 36 | Cleanify 37 | 38 | 39 | 40 |
41 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-arman/Cleanify/11a6a2a0f070b94a4fc1281c156a505267d8e820/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-arman/Cleanify/11a6a2a0f070b94a4fc1281c156a505267d8e820/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Cleanify", 3 | "name": "Cleanify", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "CleanifyLogo.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "CleanifyLogo.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import Login from "./pages/Login"; 2 | import Home from "./pages/Home"; 3 | import GlobalContextProvider from "./contexts/GlobalContext"; 4 | import posthog from "posthog-js"; 5 | const code = new URLSearchParams(window.location.search).get("code"); 6 | 7 | function App() { 8 | posthog.init("phc_7Gvql0kYGkwkTNkMOdnbi52JiYR8kJFQ3oBt5zKIc1L", { 9 | api_host: "https://us.i.posthog.com", 10 | }); 11 | 12 | return ( 13 | 14 | {code ? : } 15 | 16 | ); 17 | } 18 | 19 | export default App; 20 | -------------------------------------------------------------------------------- /client/src/assets/CleanifyHomePageV2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/code-arman/Cleanify/11a6a2a0f070b94a4fc1281c156a505267d8e820/client/src/assets/CleanifyHomePageV2.png -------------------------------------------------------------------------------- /client/src/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | IconButton, 4 | useColorModeValue, 5 | VStack, 6 | Icon, 7 | useDisclosure, 8 | CloseButton, 9 | chakra, 10 | Flex, 11 | Link, 12 | HStack, 13 | Heading, 14 | Text, 15 | Button, 16 | } from "@chakra-ui/react"; 17 | import { FaGithub, FaGripLines } from "react-icons/fa"; 18 | import { useViewportScroll } from "framer-motion"; 19 | import React from "react"; 20 | import axios from "axios"; 21 | import { useGlobalState } from "../contexts/GlobalContext"; 22 | const Header = ({ username }) => { 23 | const mobileNav = useDisclosure(); 24 | 25 | const bg = useColorModeValue("white", "gray.800"); 26 | const ref = React.useRef(); 27 | const [y, setY] = React.useState(0); 28 | const { height = 0 } = ref.current ? ref.current.getBoundingClientRect() : {}; 29 | const { setShouldLogout } = useGlobalState(); 30 | 31 | const { scrollY } = useViewportScroll(); 32 | React.useEffect(() => { 33 | return scrollY.onChange(() => setY(scrollY.get())); 34 | }, [scrollY]); 35 | 36 | const handleLogout = () => { 37 | axios 38 | .post(`${process.env.REACT_APP_CLEANIFY_BACKEND_URL}/logout`) 39 | .then(() => { 40 | localStorage.removeItem("api-key"); 41 | localStorage.setItem("logout", true); 42 | window.location = "/"; 43 | 44 | setShouldLogout(true); 45 | }) 46 | .catch((err) => { 47 | window.location = "/"; 48 | }); 49 | }; 50 | 51 | const ViewGithubButton = ( 52 | 82 | 83 | 90 | View on Github 91 | 92 | 93 | ); 94 | const MobileNavContent = ( 95 | 111 | 116 | 123 | View on Github 124 | 125 | 135 | 136 | ); 137 | return ( 138 | 139 | height ? "sm" : undefined} 142 | transition="box-shadow 0.2s" 143 | bg={bg} 144 | w="full" 145 | overflowY="hidden" 146 | > 147 | 160 | 166 | 172 | ⭐ Star this repo on Github to support ⭐ 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | Cleanify 182 | 183 | 184 | 191 | 192 | {username && {username}} 193 | {ViewGithubButton} 194 | 195 | {username && ( 196 | 202 | Logout 203 | 204 | )} 205 | 206 | } 213 | onClick={mobileNav.onOpen} 214 | /> 215 | 216 | 217 | {MobileNavContent} 218 | 219 | 220 | 221 | ); 222 | }; 223 | export default Header; 224 | -------------------------------------------------------------------------------- /client/src/components/Modals/Conflict/ConflictAccordion.jsx: -------------------------------------------------------------------------------- 1 | import { MinusIcon } from "@chakra-ui/icons"; 2 | import { 3 | Accordion, 4 | AccordionItem, 5 | AccordionButton, 6 | AccordionPanel, 7 | AccordionIcon, 8 | Box, 9 | Link, 10 | UnorderedList, 11 | Tooltip, 12 | IconButton, 13 | Flex, 14 | } from "@chakra-ui/react"; 15 | import { useGlobalState } from "../../../contexts/GlobalContext"; 16 | import SongListItem from "./SongListItem"; 17 | 18 | export const ConflictAccordion = ({ mainSong, possibleSongs, allSongs }) => { 19 | const { setSongsToResolve, songsToResolve } = useGlobalState(); 20 | 21 | const handleRemoveItemFromList = () => { 22 | const remaining = new Map(); 23 | for (let [key, value] of songsToResolve) { 24 | if (key !== mainSong) remaining.set(key, value); 25 | } 26 | setSongsToResolve(remaining); 27 | // TODO: Update summary view with changes 28 | }; 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 41 | {mainSong} 42 | 43 | 44 | 45 | 46 | 47 | 52 | } 58 | colorScheme="red" 59 | onClick={handleRemoveItemFromList} 60 | > 61 | - 62 | 63 | 64 | 65 | 66 | 67 | {possibleSongs && 68 | possibleSongs.map((item, index) => ( 69 | 77 | ))} 78 | 79 | 80 | 81 | 82 | ); 83 | }; 84 | export default ConflictAccordion; 85 | -------------------------------------------------------------------------------- /client/src/components/Modals/Conflict/ConflictModal.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Modal, 3 | ModalBody, 4 | ModalCloseButton, 5 | ModalContent, 6 | ModalFooter, 7 | ModalHeader, 8 | ModalOverlay, 9 | Text, 10 | UnorderedList, 11 | } from "@chakra-ui/react"; 12 | import ConflictAccordion from "./ConflictAccordion"; 13 | 14 | export const ConflictModal = ({ isOpen, onClose, details, type, notType }) => { 15 | const summary = `Below, there ${ 16 | details.size === 1 17 | ? `is ${details.size} song that needs to be` 18 | : `are ${details.size} songs that need to be` 19 | } resolved. Often times, the ${type} version of the song's name is not exactly the same as the ${notType} version. The names of the songs below were similar to the ${notType} version, but not exact. It is up to you to chose whether or not one of the suggested songs is actually the ${type} version. Each song title and the potential ${type} versions and linked below. Click on each one to decide whether or not to include them in your playlist.`; 20 | 21 | return ( 22 | 23 | 24 | 25 | Resolve the following conflicts 26 | 27 | 28 | 29 | {details.size === 0 30 | ? "All conflicts have been resolved :)" 31 | : summary} 32 | 33 | {details && ( 34 | 35 | {Array.from(details.entries()).map((entry, index) => { 36 | const [key, value] = entry; 37 | return ( 38 | 44 | ); 45 | })} 46 | 47 | )} 48 | 49 | 50 | 51 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /client/src/components/Modals/Conflict/SongListItem.jsx: -------------------------------------------------------------------------------- 1 | import { AddIcon, ExternalLinkIcon } from "@chakra-ui/icons"; 2 | import { 3 | Box, 4 | Flex, 5 | IconButton, 6 | Link, 7 | ListItem, 8 | Text, 9 | Tooltip, 10 | useToast, 11 | } from "@chakra-ui/react"; 12 | import React from "react"; 13 | import { useGlobalState } from "../../../contexts/GlobalContext"; 14 | import { addTracksToPlaylist } from "../../../utils/api"; 15 | const SongListItem = ({ 16 | allSongs, 17 | songTitle, 18 | songLink, 19 | songURI, 20 | ogSongTitle, 21 | }) => { 22 | const { cleanedPlaylistID, setSongsToResolve, songsToResolve } = 23 | useGlobalState(); 24 | const toast = useToast(); 25 | 26 | const handleAddToPlaylist = async (songURI) => { 27 | const response = await addTracksToPlaylist(cleanedPlaylistID, [songURI]); 28 | 29 | if (response instanceof Error) { 30 | toast({ 31 | title: `Error adding song to playlist`, 32 | position: "top-right", 33 | status: "error", 34 | duration: 5000, 35 | isClosable: true, 36 | }); 37 | 38 | return; 39 | } 40 | toast({ 41 | title: `Added song to playlist"`, 42 | position: "top-right", 43 | status: "success", 44 | duration: 2000, 45 | isClosable: true, 46 | }); 47 | handleRemoveItemFromList(); 48 | }; 49 | 50 | const handleRemoveItemFromList = () => { 51 | const remaining = new Map(); 52 | for (let [key, value] of songsToResolve) { 53 | if (key !== ogSongTitle) remaining.set(key, value); 54 | } 55 | setSongsToResolve(remaining); 56 | // TODO: Update summary view 57 | }; 58 | 59 | return ( 60 | 61 | 62 | {songTitle} 63 | 64 | 65 | } 71 | colorScheme="green" 72 | onClick={() => handleAddToPlaylist(songURI)} 73 | > 74 | + 75 | 76 | 77 | 78 | 79 | 80 | } 87 | /> 88 | 89 | 90 | 91 | 92 | 93 | ); 94 | }; 95 | 96 | export default SongListItem; 97 | -------------------------------------------------------------------------------- /client/src/components/Modals/ExplainModal.jsx: -------------------------------------------------------------------------------- 1 | import { ExternalLinkIcon } from "@chakra-ui/icons"; 2 | import { 3 | Box, 4 | Heading, 5 | Link, 6 | Modal, 7 | ModalBody, 8 | ModalCloseButton, 9 | ModalContent, 10 | ModalFooter, 11 | ModalHeader, 12 | ModalOverlay, 13 | Text, 14 | } from "@chakra-ui/react"; 15 | 16 | export const ExplainModal = ({ isOpen, onClose }) => { 17 | return ( 18 | 19 | 20 | 21 | FAQ 22 | 23 | 24 | 25 | What happens when I press "Cleanify Playlist"? 26 | 27 | 28 | 29 | { 30 | "A new playlist is created and all of songs that are already Clean (non-explicit) are added. Then, it searches Spotify for the Clean versions of the explicit songs in your playlists and adds them if they exist. If a song has no clean version, it will not be added." 31 | } 32 | 33 | 34 | 35 | What happens when I press "Explicitify Playlist"? 36 | 37 | 38 | 39 | { 40 | "A new playlist is created and all of songs that are already Explicit are added. Then, it searches Spotify for the Explicit versions of the Clean (non-explicit) songs in your playlists and adds them if they exist. If a song has no explicit version, it will still be added." 41 | } 42 | 43 | 44 | 45 | I accidentaly deleted a playlist! Can I recover it? 46 | 47 | 48 | 49 | {`You can recover deleted playlists via Spotify `} 50 | 55 | here 56 | 57 | 58 | 59 | 60 | 61 | 62 | How do I report a bug? 63 | 64 | 65 | 66 | {`You can create a new issue on the Github repository linked `} 67 | 72 | here 73 | 74 | 75 | 76 | 77 | 78 | 79 | How can I support this project? 80 | 81 | 82 | 83 | {`You can star the Github repository linked `} 84 | 89 | here 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /client/src/components/Modals/SummaryModal.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Link, 4 | ListItem, 5 | Modal, 6 | ModalBody, 7 | ModalCloseButton, 8 | ModalContent, 9 | ModalFooter, 10 | ModalHeader, 11 | ModalOverlay, 12 | OrderedList, 13 | Text, 14 | } from "@chakra-ui/react"; 15 | 16 | export const SummaryModal = ({ isOpen, onClose, details, type, notType }) => { 17 | const summary = `${details.numOriginalClean} songs were already ${type}! We ${ 18 | type === "explicit" 19 | ? `found the ${type} versions of ${ 20 | details.numCleanFound - details.numStillMissing.length 21 | } songs. ${ 22 | details.numStillMissing.length 23 | } clean songs were still added since no explicit version was found` 24 | : `found the ${type} versions of ${details.numCleanFound} songs.` 25 | }`; 26 | return ( 27 | 28 | 29 | 30 | Summary 31 | 32 | 33 | {summary} 34 | {details.numStillMissing.length > 0 ? ( 35 | 36 | {`Unable to find the ${type} version to these ${details.numStillMissing.length} songs. You can try clicking these links to see if you can find a ${type} version that exists`} 37 | 38 | {details.numStillMissing && 39 | details.numStillMissing.map(({ name, queryURL }, index) => ( 40 | 41 | 42 | {name} 43 | 44 | 45 | ))} 46 | {" "} 47 | 48 | ) : ( 49 | <> 50 | )} 51 | 52 | 53 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /client/src/components/ProgressBar.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Center, 3 | CircularProgress, 4 | CircularProgressLabel, 5 | Text, 6 | } from "@chakra-ui/react"; 7 | 8 | const ProgressBar = ({ value }) => { 9 | return ( 10 |
11 | 12 | Conversion Progress 13 | 14 | 15 | {Math.round(value)}% 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default ProgressBar; 22 | -------------------------------------------------------------------------------- /client/src/components/Tables/CleanSongTable.jsx: -------------------------------------------------------------------------------- 1 | import { Container } from "@chakra-ui/react"; 2 | import { useEffect, useMemo, useState } from "react"; 3 | import { useGlobalState } from "../../contexts/GlobalContext"; 4 | import { getTracks } from "../../utils/api"; 5 | import CustomTable from "./CustomTable"; 6 | const CleanSongTable = ({ title }) => { 7 | const { cleanedPlaylistID } = useGlobalState(); 8 | const [cleanedTracks, setCleanedTracks] = useState(); 9 | useEffect(() => { 10 | const getCleanedTracks = async () => { 11 | setCleanedTracks(await getTracks(cleanedPlaylistID)); 12 | }; 13 | getCleanedTracks(); 14 | }, [cleanedPlaylistID]); 15 | const columns = useMemo( 16 | () => [ 17 | { 18 | Header: title, 19 | accessor: "name", 20 | }, 21 | ], 22 | [title] 23 | ); 24 | const data = []; 25 | 26 | cleanedTracks && 27 | cleanedTracks.items.map((t) => data.push({ name: t.track.name })); 28 | return ( 29 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default CleanSongTable; 36 | -------------------------------------------------------------------------------- /client/src/components/Tables/CustomTable.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTable, usePagination } from "react-table"; 3 | import { 4 | Table, 5 | Thead, 6 | Tbody, 7 | Tr, 8 | Th, 9 | Td, 10 | Flex, 11 | IconButton, 12 | Text, 13 | Tooltip, 14 | VStack, 15 | RadioGroup, 16 | } from "@chakra-ui/react"; 17 | import { 18 | ArrowRightIcon, 19 | ArrowLeftIcon, 20 | ChevronRightIcon, 21 | ChevronLeftIcon, 22 | } from "@chakra-ui/icons"; 23 | import { useGlobalState } from "../../contexts/GlobalContext"; 24 | 25 | function CustomTable({ columns, data, hasRadio }) { 26 | const { checkedPlaylist, setCheckedPlaylist, setCleanedPlaylistID } = 27 | useGlobalState(); 28 | 29 | const { 30 | getTableProps, 31 | getTableBodyProps, 32 | headerGroups, 33 | prepareRow, 34 | page, 35 | canPreviousPage, 36 | canNextPage, 37 | pageOptions, 38 | pageCount, 39 | gotoPage, 40 | nextPage, 41 | previousPage, 42 | state: { pageIndex }, 43 | } = useTable( 44 | { 45 | columns, 46 | data, 47 | initialState: { pageIndex: 0 }, 48 | }, 49 | usePagination 50 | ); 51 | 52 | return ( 53 | 54 | 55 | 56 | {headerGroups.map((headerGroup) => { 57 | return ( 58 | 62 | {headerGroup.headers.map((column) => { 63 | return ( 64 | 67 | ); 68 | })} 69 | 70 | ); 71 | })} 72 | 73 | 74 | {page.map((row) => { 75 | prepareRow(row); 76 | return hasRadio ? ( 77 | { 80 | setCheckedPlaylist(value); 81 | setCleanedPlaylistID(""); 82 | }} 83 | value={checkedPlaylist} 84 | as={Tr} 85 | {...row.getRowProps()} 86 | > 87 | {row.cells.map((cell) => { 88 | return ( 89 | 92 | ); 93 | })} 94 | 95 | ) : ( 96 | 97 | {row.cells.map((cell) => { 98 | return ( 99 | 102 | ); 103 | })} 104 | 105 | ); 106 | })} 107 | 108 |
65 | {column.render("Header")} 66 |
90 | {cell.render("Cell")} 91 |
100 | {cell.render("Cell")} 101 |
109 | 110 | 111 | 112 | gotoPage(0)} 114 | isDisabled={!canPreviousPage} 115 | icon={} 116 | mr={4} 117 | /> 118 | 119 | 120 | } 124 | /> 125 | 126 | 127 | 128 | 129 | 130 | {" "} 131 | 132 | {pageIndex + 1} 133 | {" "} 134 | of{" "} 135 | 136 | {pageOptions.length} 137 | 138 | 139 | 140 | 141 | 142 | 143 | } 147 | /> 148 | 149 | 150 | gotoPage(pageCount - 1)} 152 | isDisabled={!canNextPage} 153 | icon={} 154 | ml={4} 155 | /> 156 | 157 | 158 | 159 |
160 | ); 161 | } 162 | 163 | export default CustomTable; 164 | -------------------------------------------------------------------------------- /client/src/components/Tables/PlaylistTable.jsx: -------------------------------------------------------------------------------- 1 | import { Center, Container, Radio, Spinner, Text } from "@chakra-ui/react"; 2 | import { useEffect, useMemo } from "react"; 3 | import { useGlobalState } from "../../contexts/GlobalContext"; 4 | import { getPlaylists } from "../../utils/api"; 5 | import CustomTable from "./CustomTable"; 6 | 7 | const PlaylistTable = () => { 8 | const { playlists, setPlaylists } = useGlobalState(); 9 | 10 | useEffect(() => { 11 | const loadPlaylists = async () => { 12 | const p = await getPlaylists(); 13 | setPlaylists(p); 14 | }; 15 | loadPlaylists(); 16 | }, [setPlaylists]); 17 | 18 | const data = []; 19 | playlists && 20 | playlists.items.map((playlist, index) => 21 | data.push({ 22 | entry: { name: playlist.name, idx: String(index) }, 23 | }) 24 | ); 25 | 26 | const columns = useMemo( 27 | () => [ 28 | { 29 | Header: `Playlists`, 30 | accessor: "entry", 31 | Cell: ({ value: { name, idx } }) => ( 32 | {name} 33 | ), 34 | }, 35 | ], 36 | [] 37 | ); 38 | 39 | return data.length === 0 ? ( 40 |
41 | Fetching playlists 42 | 43 |
44 | ) : ( 45 | 46 | 47 | 48 | ); 49 | }; 50 | export default PlaylistTable; 51 | -------------------------------------------------------------------------------- /client/src/components/Tables/SongTable.jsx: -------------------------------------------------------------------------------- 1 | import { Center, Container, Spinner, Text } from "@chakra-ui/react"; 2 | import { useMemo } from "react"; 3 | import { useGlobalState } from "../../contexts/GlobalContext"; 4 | import CustomTable from "./CustomTable"; 5 | const SongTable = ({ title }) => { 6 | const { tracks } = useGlobalState(); 7 | 8 | const columns = useMemo( 9 | () => [ 10 | { 11 | Header: title, 12 | accessor: "name", 13 | }, 14 | ], 15 | [title] 16 | ); 17 | const data = []; 18 | 19 | tracks && tracks.items.map((t) => data.push({ name: t.track?.name })); 20 | return data.length === 0 ? ( 21 |
22 | Fetching tracks from playlist 23 | 24 |
25 | ) : ( 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default SongTable; 33 | -------------------------------------------------------------------------------- /client/src/contexts/GlobalContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from "react"; 2 | 3 | const GlobalContext = createContext(""); 4 | 5 | const GlobalContextProvider = ({ children }) => { 6 | const [token, setToken] = useState(""); 7 | const [checkedPlaylist, setCheckedPlaylist] = useState(); 8 | const [playlists, setPlaylists] = useState(); 9 | const [tracks, setTracks] = useState(); 10 | const [cleanedPlaylistID, setCleanedPlaylistID] = useState(); 11 | const [songsToResolve, setSongsToResolve] = useState([]); 12 | const [shouldLogout, setShouldLogout] = useState(false); 13 | 14 | return ( 15 | 33 | {children} 34 | 35 | ); 36 | }; 37 | 38 | export default GlobalContextProvider; 39 | 40 | export const useGlobalState = () => { 41 | const context = useContext(GlobalContext); 42 | 43 | if (!context) { 44 | throw new Error( 45 | "useGlobalState must be used inside the GlobalContext provider" 46 | ); 47 | } 48 | 49 | return context; 50 | }; 51 | -------------------------------------------------------------------------------- /client/src/hooks/useAuth.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import axios from "axios"; 3 | import { useGlobalState } from "../contexts/GlobalContext"; 4 | import { spotifyApi } from "../pages/Home"; 5 | 6 | export default function useAuth(code) { 7 | const [accessToken, setAccessToken] = useState(); 8 | const [refreshToken, setRefreshToken] = useState(); 9 | const [expiresIn, setExpiresIn] = useState(); 10 | const { setToken } = useGlobalState(); 11 | 12 | useEffect(() => { 13 | axios 14 | .post(`${process.env.REACT_APP_CLEANIFY_BACKEND_URL}/login`, { 15 | code: code, 16 | }) 17 | .then((res) => { 18 | setAccessToken(res.data.accessToken); 19 | setToken(res.data.accessToken); 20 | localStorage.setItem("api-key", res.data.accessToken); 21 | 22 | setRefreshToken(res.data.refreshToken); 23 | setExpiresIn(res.data.expiresIn); 24 | window.history.pushState({}, null, "/"); 25 | }) 26 | .catch((err) => { 27 | window.location = "/"; 28 | }); 29 | }, [code, setToken]); 30 | 31 | useEffect(() => { 32 | if (!refreshToken || !expiresIn) return; 33 | const interval = setInterval(() => { 34 | axios 35 | .post(`${process.env.CLEANIFY_BACKEND_URL}/refresh`, { 36 | refreshToken, 37 | }) 38 | .then((res) => { 39 | setAccessToken(res.data.accessToken); 40 | setToken(res.data.accessToken); 41 | spotifyApi.setAccessToken(res.data.accessToken); 42 | localStorage.setItem("api-key", res.data.accessToken); 43 | setExpiresIn(res.data.expiresIn); 44 | }) 45 | .catch(() => { 46 | window.location = "/"; 47 | }); 48 | }, (expiresIn - 60) * 1000); 49 | 50 | return () => clearInterval(interval); 51 | }, [refreshToken, expiresIn, setToken]); 52 | 53 | return accessToken; 54 | } 55 | -------------------------------------------------------------------------------- /client/src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | import { ChakraProvider } from "@chakra-ui/react"; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | 10 | 11 | , 12 | document.getElementById("root") 13 | ); 14 | -------------------------------------------------------------------------------- /client/src/pages/Home.jsx: -------------------------------------------------------------------------------- 1 | import { Flex, VStack, Center } from "@chakra-ui/layout"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import { 4 | createPlaylist, 5 | getTracks, 6 | getNextTracks, 7 | getUser, 8 | deletePlaylist, 9 | addTracksToPlaylist, 10 | getPlaylists, 11 | searchForTracks, 12 | } from "../utils/api.js"; 13 | import { 14 | Button, 15 | SimpleGrid, 16 | Container, 17 | useToast, 18 | useDisclosure, 19 | Box, 20 | Heading, 21 | Icon, 22 | } from "@chakra-ui/react"; 23 | import PlaylistTable from "../components/Tables/PlaylistTable.jsx"; 24 | import SongTable from "../components/Tables/SongTable.jsx"; 25 | import { useGlobalState } from "../contexts/GlobalContext.jsx"; 26 | import CleanSongTable from "../components/Tables/CleanSongTable.jsx"; 27 | import { SummaryModal } from "../components/Modals/SummaryModal.jsx"; 28 | import useAuth from "../hooks/useAuth.jsx"; 29 | import SpotifyWebApi from "spotify-web-api-node"; 30 | import { ConflictModal } from "../components/Modals/Conflict/ConflictModal.jsx"; 31 | import ProgressBar from "../components/ProgressBar.jsx"; 32 | import { CLIENT_ID } from "../utils/Constants.jsx"; 33 | import Header from "../components/Header.jsx"; 34 | import { ExplainModal } from "../components/Modals/ExplainModal.jsx"; 35 | const fuzzball = require("fuzzball"); 36 | 37 | export const spotifyApi = new SpotifyWebApi({ 38 | clientId: CLIENT_ID, 39 | }); 40 | 41 | const Home = ({ code }) => { 42 | const [isLoading, setIsLoading] = useState(true); 43 | const accessToken = useAuth(code); 44 | const [user, setUser] = useState(); 45 | const { setToken, setCheckedPlaylist, songsToResolve, setSongsToResolve } = 46 | useGlobalState(); 47 | const [cleanifyStatus, setCleanifyStatus] = useState(false); 48 | const [deleteStatus, setDeleteStatus] = useState(false); 49 | const [cleanifyProgress, setCleanifyProgress] = useState(false); 50 | const [gettingTracks, setGettingTracks] = useState(false); 51 | const [wantedExplicit, setWantedExplicit] = useState(true); 52 | 53 | useEffect(() => { 54 | if (!accessToken) return; 55 | spotifyApi.setAccessToken(accessToken); 56 | setToken(accessToken); 57 | localStorage.setItem("api-key", accessToken); 58 | setIsLoading(false); 59 | }, [accessToken, setToken]); 60 | 61 | const { 62 | isOpen: isSummaryOpen, 63 | onOpen: onSummaryOpen, 64 | onClose: onSummaryClose, 65 | } = useDisclosure(); 66 | 67 | const { 68 | isOpen: isExplainOpen, 69 | onOpen: onExplainOpen, 70 | onClose: onExplainClose, 71 | } = useDisclosure(); 72 | 73 | const { 74 | isOpen: isResolveOpen, 75 | onOpen: onResolveOpen, 76 | onClose: onResolveClose, 77 | } = useDisclosure(); 78 | const [isCleanifyLoading, setisCleanifyLoading] = useState(); 79 | 80 | const toast = useToast(); 81 | 82 | const { 83 | checkedPlaylist, 84 | playlists, 85 | tracks, 86 | setPlaylists, 87 | setTracks, 88 | cleanedPlaylistID, 89 | setCleanedPlaylistID, 90 | } = useGlobalState(); 91 | 92 | useEffect(() => { 93 | const loadUser = async () => { 94 | try { 95 | setUser(await getUser()); 96 | } catch (e) { 97 | toast({ 98 | title: `Unable to perform action. Please try refreshing the page and log in again`, 99 | position: "top", 100 | status: "error", 101 | duration: 7000, 102 | isClosable: true, 103 | }); 104 | } 105 | }; 106 | if (accessToken) { 107 | loadUser(); 108 | } 109 | }, [accessToken, toast]); 110 | 111 | const handleDelete = async () => { 112 | setDeleteStatus(true); 113 | setCheckedPlaylist( 114 | String(Number(checkedPlaylist) - 1) >= 0 115 | ? String(Number(checkedPlaylist) - 1) 116 | : "" 117 | ); 118 | 119 | await deletePlaylist(playlists.items[checkedPlaylist].id); 120 | const refreshedPlaylists = await getPlaylists(); 121 | if (refreshedPlaylists instanceof Error) { 122 | toast({ 123 | title: `Unable to perform action. Please try refreshing the page and log in again`, 124 | position: "top", 125 | status: "error", 126 | duration: 7000, 127 | isClosable: true, 128 | }); 129 | 130 | return; 131 | } 132 | setPlaylists(refreshedPlaylists); 133 | setDeleteStatus(false); 134 | }; 135 | 136 | const negate = (condition, shouldNegate) => { 137 | return shouldNegate ? condition : !condition; 138 | }; 139 | 140 | const containSameArtists = (first, second) => { 141 | if (first.artists.length !== second.artists.length) return false; 142 | let artistCount = first.artists.length; 143 | for (let index = 0; index < artistCount; index++) { 144 | if (first.artists[index].name !== second.artists[index].name) { 145 | return false; 146 | } 147 | } 148 | return true; 149 | }; 150 | 151 | const getAllTracks = useCallback(async () => { 152 | setGettingTracks(true); 153 | setTracks({ items: [] }); 154 | 155 | const allTracks = []; 156 | let tracks = await getTracks(playlists.items[checkedPlaylist].id); 157 | if (tracks instanceof Error) { 158 | toast({ 159 | title: `Unable to perform action. Please try refreshing the page and log in again`, 160 | position: "top", 161 | status: "error", 162 | duration: 7000, 163 | isClosable: true, 164 | }); 165 | return; 166 | } 167 | if (!tracks) { 168 | toast({ 169 | title: `Error fetching all tracks. Refresh and try again`, 170 | position: "top", 171 | status: "error", 172 | duration: 7000, 173 | isClosable: true, 174 | }); 175 | } 176 | allTracks.push(...tracks.items); 177 | while (tracks && tracks.next) { 178 | tracks = await getNextTracks(tracks.next); 179 | if (!tracks) { 180 | toast({ 181 | title: `Error fetching all tracks. Refresh and try again`, 182 | position: "top", 183 | status: "error", 184 | duration: 7000, 185 | isClosable: true, 186 | }); 187 | } 188 | if (tracks && tracks.items) { 189 | allTracks.push(...tracks.items); 190 | } 191 | } 192 | tracks = { items: allTracks }; 193 | 194 | setTracks(tracks); 195 | setGettingTracks(false); 196 | return allTracks; 197 | // eslint-disable-next-line react-hooks/exhaustive-deps 198 | }, [checkedPlaylist, setTracks, toast]); 199 | 200 | useEffect(() => { 201 | if (checkedPlaylist && checkedPlaylist >= 0) { 202 | getAllTracks(); 203 | } 204 | }, [checkedPlaylist, getAllTracks]); 205 | 206 | const handleCleanify = async (shouldExplicitify) => { 207 | try { 208 | setCleanifyStatus(true); 209 | setWantedExplicit(shouldExplicitify); 210 | 211 | const cleanTrackIDs = []; 212 | const explicitTracks = []; 213 | 214 | for (let t of tracks.items) { 215 | if (!t.track) continue; 216 | t && t.track && negate(t.track.explicit, shouldExplicitify) 217 | ? explicitTracks.push({ 218 | query: `${t.track.name} ${t.track.artists[0].name}`, 219 | name: t.track.name, 220 | artists: t.track.artists, 221 | uri: t.track.uri, 222 | link: t.track.external_urls.spotify, 223 | }) 224 | : cleanTrackIDs.push(t.track.uri); 225 | } 226 | 227 | const cleanVersionTrackIDs = []; 228 | const remainingExplicitSongs = []; 229 | const potentiallyCleanSongs = new Map(); 230 | 231 | const total = explicitTracks.length; 232 | let index = 0; 233 | for (let track of explicitTracks) { 234 | index++; 235 | if (track.query.length === 0) continue; 236 | const trackResponses = await searchForTracks( 237 | track.query.trim().replaceAll("#", "") 238 | ); 239 | if (!trackResponses) { 240 | toast({ 241 | title: `Error searching for track. Refresh and try again`, 242 | position: "top", 243 | status: "error", 244 | duration: 7000, 245 | isClosable: true, 246 | }); 247 | } 248 | if (trackResponses instanceof Error) { 249 | toast({ 250 | title: `Error while converting. Your playlist may be too big. Refresh and try again`, 251 | position: "top", 252 | status: "error", 253 | duration: 7000, 254 | isClosable: true, 255 | }); 256 | setCleanifyStatus(false); 257 | return; 258 | } 259 | let isClean = false; 260 | if (trackResponses && trackResponses.tracks.items.length > 0) { 261 | for (let t of trackResponses.tracks.items) { 262 | if ( 263 | t && 264 | t.name && 265 | negate(!t.explicit, shouldExplicitify) && 266 | containSameArtists(t, track) 267 | ) { 268 | if (fuzzball.distance(t.name, track.name) === 0) { 269 | cleanVersionTrackIDs.push(t.uri); 270 | isClean = true; 271 | break; 272 | } else if (fuzzball.ratio(t.name, track.name) > 1) { 273 | if (potentiallyCleanSongs.has(track.name)) { 274 | potentiallyCleanSongs.get(track.name).push({ 275 | name: t.name, 276 | link: t.external_urls.spotify, 277 | uri: t.uri, 278 | original_track_uri: track.uri, 279 | original_track_link: track.link, 280 | }); 281 | } else { 282 | potentiallyCleanSongs.set(track.name, [ 283 | { 284 | name: t.name, 285 | link: t.external_urls.spotify, 286 | uri: t.uri, 287 | original_track_uri: track.uri, 288 | original_track_link: track.link, 289 | }, 290 | ]); 291 | } 292 | } 293 | } 294 | } 295 | if (!isClean) { 296 | remainingExplicitSongs.push({ 297 | name: track.name, 298 | queryURL: `https://open.spotify.com/search/${encodeURIComponent( 299 | track.query 300 | )}`, 301 | }); 302 | if (!shouldExplicitify) { 303 | cleanVersionTrackIDs.push(track.uri); 304 | } 305 | } 306 | } 307 | setCleanifyProgress((index / total) * 100); 308 | } 309 | 310 | setSongsToResolve(potentiallyCleanSongs); 311 | 312 | const newPlaylist = await createPlaylist( 313 | `${playlists.items[checkedPlaylist].name} (${ 314 | shouldExplicitify ? "All Clean" : "Explicit" 315 | })`, 316 | user.id 317 | ); 318 | setPlaylists(await getPlaylists()); 319 | 320 | let allCleanSongs = [...cleanTrackIDs, ...cleanVersionTrackIDs]; 321 | let remainingSongs = []; 322 | 323 | while (allCleanSongs.length > 0) { 324 | remainingSongs = allCleanSongs.splice(0, 100); 325 | if (remainingSongs.length > 0) { 326 | await addTracksToPlaylist(newPlaylist.id, remainingSongs); 327 | } 328 | } 329 | 330 | setisCleanifyLoading({ 331 | numOriginalClean: cleanTrackIDs.length, 332 | numCleanFound: cleanVersionTrackIDs.length, 333 | numStillMissing: remainingExplicitSongs, 334 | }); 335 | setCheckedPlaylist(String(Number(checkedPlaylist) + 1)); 336 | setCleanedPlaylistID(newPlaylist.id); 337 | setCleanifyStatus(false); 338 | toast({ 339 | title: `${shouldExplicitify ? "Cleanified" : "Explicitified"} Playlist`, 340 | position: "top", 341 | status: "success", 342 | duration: 4000, 343 | isClosable: true, 344 | }); 345 | } catch (e) { 346 | console.log("Error converting", e); 347 | toast({ 348 | title: `Error while converting. Your playlist may be too big. Refresh and try again`, 349 | position: "top", 350 | status: "error", 351 | duration: 4000, 352 | isClosable: true, 353 | }); 354 | } 355 | }; 356 | 357 | return isLoading ? ( 358 | <> 359 | ) : ( 360 | 361 |
362 | 363 | 364 | {user && ( 365 | 366 | Select a Playlist to Convert 367 | 368 | 369 | 370 | )} 371 | {user && ( 372 | 373 | 388 | 389 | 404 | 405 | 414 | 415 | {cleanedPlaylistID && ( 416 | 419 | )} 420 | {cleanedPlaylistID && songsToResolve.size !== 0 && ( 421 | 428 | )} 429 | 430 | )} 431 | {isCleanifyLoading && ( 432 | 439 | )} 440 | {songsToResolve && ( 441 | 448 | )} 449 | 455 | 461 | {user && } 462 | 463 | {checkedPlaylist && ( 464 | 470 | {checkedPlaylist && ( 471 | 476 | )} 477 | 478 | )} 479 | {((checkedPlaylist && cleanifyProgress) || 480 | (checkedPlaylist && cleanedPlaylistID)) && ( 481 | 487 | {checkedPlaylist && cleanedPlaylistID ? ( 488 | 494 | ) : ( 495 | cleanifyProgress && 496 | cleanifyProgress !== 100 && ( 497 |
498 | 499 |
500 | ) 501 | )} 502 |
503 | )} 504 |
505 |
506 |
507 | 508 | ); 509 | }; 510 | 511 | export default Home; 512 | -------------------------------------------------------------------------------- /client/src/pages/Login.jsx: -------------------------------------------------------------------------------- 1 | import { Text } from "@chakra-ui/layout"; 2 | import { AUTH_ENDPOINT } from "../utils/Constants"; 3 | import { 4 | Button, 5 | Box, 6 | Icon, 7 | Image, 8 | Stack, 9 | useColorModeValue, 10 | chakra, 11 | } from "@chakra-ui/react"; 12 | import CleanifyHomePage from "../assets/CleanifyHomePageV2.png"; 13 | import Header from "../components/Header"; 14 | 15 | function Login() { 16 | const handleLogin = () => { 17 | const logout = localStorage.getItem("logout"); 18 | JSON.parse(logout) === true 19 | ? (window.location.href = `${AUTH_ENDPOINT}&show_dialog=true`) 20 | : (window.location.href = AUTH_ENDPOINT); 21 | localStorage.setItem("logout", false); 22 | }; 23 | return ( 24 | 25 |
26 | 27 | 28 | 33 | 41 | Clean your{" "} 42 | 49 | Spotify Playlists 50 | {" "} 51 | the easy way. 52 | 53 | 59 | Cleanify allows you to easily create completely clean or explicit 60 | versions of your Spotify playlists 61 | 62 | 68 | 92 | 93 | 94 | 100 | Cleanify Home Page screenshot 107 | 108 | 109 | 110 | ); 111 | } 112 | 113 | export default Login; 114 | -------------------------------------------------------------------------------- /client/src/utils/Constants.jsx: -------------------------------------------------------------------------------- 1 | export const CLIENT_ID = process.env.REACT_APP_SPOTIFY_CLIENT_ID; 2 | const SPOTIFY_AUTH_ENDPOINT = "https://accounts.spotify.com/authorize"; 3 | const SCOPES = [ 4 | "playlist-modify-public", 5 | "playlist-modify-private", 6 | "playlist-read-private", 7 | "playlist-read-collaborative", 8 | ]; 9 | const RESPONSE_TYPE = "code"; 10 | const REDIRECT_URI = process.env.REACT_APP_SPOTIFY_REDIRECT_URI; 11 | 12 | export const AUTH_ENDPOINT = `${SPOTIFY_AUTH_ENDPOINT}?client_id=${CLIENT_ID}&response_type=${RESPONSE_TYPE}&redirect_uri=${REDIRECT_URI.slice( 13 | 0, 14 | -1 15 | )}&scope=${SCOPES.join("%20")}`; 16 | -------------------------------------------------------------------------------- /client/src/utils/api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const instance = axios.create(); 4 | const wait = (next_retry_time) => 5 | new Promise((res) => setTimeout(res, next_retry_time * 1000)); 6 | 7 | instance.interceptors.request.use((config) => { 8 | const token = localStorage.getItem("api-key"); 9 | if (token) { 10 | config.headers = { 11 | Authorization: `Bearer ${token}`, 12 | "Content-Type": "application/x-www-form-urlencoded", 13 | }; 14 | } 15 | 16 | return config; 17 | }); 18 | 19 | instance.interceptors.response.use( 20 | (response) => response.data, 21 | async (error) => { 22 | const originalRequest = error.config; 23 | 24 | if (error.response.status === 429) { 25 | const next_retry_time = error.response.headers["retry-after"]; 26 | console.log("Rate limited, retry after", next_retry_time); 27 | if (next_retry_time) { 28 | wait(next_retry_time); 29 | return instance.request(error.config); 30 | } 31 | } else if (error.response.status === 404) { 32 | originalRequest._retry = true; 33 | return instance(originalRequest); 34 | } 35 | 36 | console.log("API ERROR", error); 37 | } 38 | ); 39 | 40 | export default instance; 41 | 42 | export const getUser = () => 43 | handleResponse(instance.get("https://api.spotify.com/v1/me")); 44 | 45 | export const getPlaylists = () => 46 | handleResponse(instance.get("https://api.spotify.com/v1/me/playlists")); 47 | 48 | export const getTracks = (playlistID) => 49 | handleResponse( 50 | instance.get(`https://api.spotify.com/v1/playlists/${playlistID}/tracks`) 51 | ); 52 | 53 | export const getNextTracks = (next) => handleResponse(instance.get(next)); 54 | 55 | export const createPlaylist = (playlistName, userId) => 56 | handleResponse( 57 | instance.post(`https://api.spotify.com/v1/users/${userId}/playlists`, { 58 | name: playlistName, 59 | public: false, 60 | description: `Created with www.Cleanify.app`, 61 | }) 62 | ); 63 | 64 | export const deletePlaylist = (playlistID) => 65 | handleResponse( 66 | instance.delete( 67 | `https://api.spotify.com/v1/playlists/${playlistID}/followers` 68 | ) 69 | ); 70 | 71 | export const addTracksToPlaylist = (playlistID, trackIDs) => 72 | handleResponse( 73 | instance.post(`https://api.spotify.com/v1/playlists/${playlistID}/tracks`, { 74 | uris: trackIDs, 75 | }) 76 | ); 77 | 78 | export const searchForTracks = (trackName) => 79 | handleResponse( 80 | instance.get( 81 | `https://api.spotify.com/v1/search?q=${encodeURI(trackName)}&type=track` 82 | ) 83 | ); 84 | class APIError extends Error { 85 | name = "APIError"; 86 | } 87 | 88 | const handleResponse = (request) => 89 | request 90 | .then((res) => { 91 | return res; 92 | }) 93 | .catch((error) => { 94 | const message = error.response?.data.error; 95 | if (message) { 96 | console.error("APIError:", message, error); 97 | if (message === "Invalid access token") { 98 | localStorage.removeItem("api-key"); 99 | } 100 | return new APIError({ ...error, message }); 101 | } else { 102 | return error; 103 | } 104 | }); 105 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Cleanify", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const app = express(); 3 | const SpotifyWebApi = require("spotify-web-api-node"); 4 | const bodyParser = require("body-parser"); 5 | const cors = require("cors"); 6 | const path = require("path"); 7 | require("dotenv").config(); 8 | 9 | app.use(cors()); 10 | app.use(bodyParser.json()); 11 | app.use(bodyParser.urlencoded({ extended: true })); 12 | 13 | app.post("/api/login", (req, res) => { 14 | const code = req.body.code; 15 | const spotifyApi = new SpotifyWebApi({ 16 | redirectUri: process.env.CLEANIFY_FRONTEND_URL, 17 | clientId: process.env.SPOTIFY_CLIENT_ID, 18 | clientSecret: process.env.SPOTIFY_CLIENT_SECRET, 19 | }); 20 | 21 | spotifyApi 22 | .authorizationCodeGrant(code) 23 | .then((data) => { 24 | res.json({ 25 | accessToken: data.body.access_token, 26 | refreshToken: data.body.refresh_token, 27 | expiresIn: data.body.expires_in, 28 | }); 29 | }) 30 | .catch((err) => { 31 | res.sendStatus(400); 32 | }); 33 | }); 34 | 35 | app.post("/api/refresh", (req, res) => { 36 | console.log("Refresh token endpoint called"); 37 | const refreshToken = req.body.refreshToken; 38 | const spotifyApi = new SpotifyWebApi({ 39 | redirectUri: process.env.CLEANIFY_FRONTEND_URL, 40 | clientId: process.env.SPOTIFY_CLIENT_ID, 41 | clientSecret: process.env.SPOTIFY_CLIENT_SECRET, 42 | refreshToken, 43 | }); 44 | 45 | spotifyApi 46 | .refreshAccessToken() 47 | .then((data) => { 48 | res.json({ 49 | accessToken: data.body.accessToken, 50 | expiresIn: data.body.expiresIn, 51 | }); 52 | }) 53 | .catch((err) => { 54 | console.log("Error when refreshing access token", err); 55 | res.sendStatus(400); 56 | }); 57 | }); 58 | 59 | app.post("/api/logout", (req, res) => { 60 | const spotifyApi = new SpotifyWebApi({ 61 | redirectUri: process.env.CLEANIFY_FRONTEND_URL, 62 | clientId: process.env.SPOTIFY_CLIENT_ID, 63 | clientSecret: process.env.SPOTIFY_CLIENT_SECRET, 64 | }); 65 | spotifyApi.resetCredentials(); 66 | res.sendStatus(200); 67 | }); 68 | 69 | app.use(express.static(path.join(__dirname, "client/build"))); 70 | 71 | app.get("*", (_, res) => { 72 | res.sendFile(path.join(__dirname, "client/build", "index.html"), (err) => { 73 | if (err) { 74 | res.status(500).send(err); 75 | } 76 | }); 77 | }); 78 | 79 | const API_PORT = process.env.PORT || 9000; 80 | 81 | app.listen(API_PORT, (err) => { 82 | if (err) { 83 | console.log(err); 84 | return; 85 | } 86 | console.log(`Server live at ${API_PORT}`); 87 | }); 88 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "body-parser": "^1.19.2", 4 | "cors": "^2.8.5", 5 | "dotenv": "^16.0.0", 6 | "express": "^4.17.3", 7 | "path": "^0.12.7", 8 | "spotify-web-api-node": "^5.0.2" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | accepts@~1.3.8: 6 | version "1.3.8" 7 | resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" 8 | integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== 9 | dependencies: 10 | mime-types "~2.1.34" 11 | negotiator "0.6.3" 12 | 13 | array-flatten@1.1.1: 14 | version "1.1.1" 15 | resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" 16 | integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= 17 | 18 | asynckit@^0.4.0: 19 | version "0.4.0" 20 | resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" 21 | integrity sha1-x57Zf380y48robyXkLzDZkdLS3k= 22 | 23 | body-parser@1.19.2, body-parser@^1.19.2: 24 | version "1.19.2" 25 | resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.2.tgz#4714ccd9c157d44797b8b5607d72c0b89952f26e" 26 | integrity sha512-SAAwOxgoCKMGs9uUAUFHygfLAyaniaoun6I8mFY9pRAJL9+Kec34aU+oIjDhTycub1jozEfEwx1W1IuOYxVSFw== 27 | dependencies: 28 | bytes "3.1.2" 29 | content-type "~1.0.4" 30 | debug "2.6.9" 31 | depd "~1.1.2" 32 | http-errors "1.8.1" 33 | iconv-lite "0.4.24" 34 | on-finished "~2.3.0" 35 | qs "6.9.7" 36 | raw-body "2.4.3" 37 | type-is "~1.6.18" 38 | 39 | bytes@3.1.2: 40 | version "3.1.2" 41 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" 42 | integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== 43 | 44 | call-bind@^1.0.0: 45 | version "1.0.2" 46 | resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" 47 | integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== 48 | dependencies: 49 | function-bind "^1.1.1" 50 | get-intrinsic "^1.0.2" 51 | 52 | combined-stream@^1.0.8: 53 | version "1.0.8" 54 | resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" 55 | integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== 56 | dependencies: 57 | delayed-stream "~1.0.0" 58 | 59 | component-emitter@^1.3.0: 60 | version "1.3.0" 61 | resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" 62 | integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== 63 | 64 | content-disposition@0.5.4: 65 | version "0.5.4" 66 | resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" 67 | integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== 68 | dependencies: 69 | safe-buffer "5.2.1" 70 | 71 | content-type@~1.0.4: 72 | version "1.0.4" 73 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" 74 | integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== 75 | 76 | cookie-signature@1.0.6: 77 | version "1.0.6" 78 | resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" 79 | integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= 80 | 81 | cookie@0.4.2: 82 | version "0.4.2" 83 | resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" 84 | integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== 85 | 86 | cookiejar@^2.1.2: 87 | version "2.1.3" 88 | resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.3.tgz#fc7a6216e408e74414b90230050842dacda75acc" 89 | integrity sha512-JxbCBUdrfr6AQjOXrxoTvAMJO4HBTUIlBzslcJPAz+/KT8yk53fXun51u+RenNYvad/+Vc2DIz5o9UxlCDymFQ== 90 | 91 | cors@^2.8.5: 92 | version "2.8.5" 93 | resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" 94 | integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== 95 | dependencies: 96 | object-assign "^4" 97 | vary "^1" 98 | 99 | debug@2.6.9: 100 | version "2.6.9" 101 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" 102 | integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 103 | dependencies: 104 | ms "2.0.0" 105 | 106 | debug@^4.1.1: 107 | version "4.3.4" 108 | resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" 109 | integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== 110 | dependencies: 111 | ms "2.1.2" 112 | 113 | delayed-stream@~1.0.0: 114 | version "1.0.0" 115 | resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" 116 | integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= 117 | 118 | depd@~1.1.2: 119 | version "1.1.2" 120 | resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" 121 | integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= 122 | 123 | destroy@~1.0.4: 124 | version "1.0.4" 125 | resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" 126 | integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= 127 | 128 | dotenv@^16.0.0: 129 | version "16.0.0" 130 | resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.0.tgz#c619001253be89ebb638d027b609c75c26e47411" 131 | integrity sha512-qD9WU0MPM4SWLPJy/r2Be+2WgQj8plChsyrCNQzW/0WjvcJQiKQJ9mH3ZgB3fxbUUxgc/11ZJ0Fi5KiimWGz2Q== 132 | 133 | ee-first@1.1.1: 134 | version "1.1.1" 135 | resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" 136 | integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= 137 | 138 | encodeurl@~1.0.2: 139 | version "1.0.2" 140 | resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" 141 | integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= 142 | 143 | escape-html@~1.0.3: 144 | version "1.0.3" 145 | resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" 146 | integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= 147 | 148 | etag@~1.8.1: 149 | version "1.8.1" 150 | resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" 151 | integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= 152 | 153 | express@^4.17.3: 154 | version "4.17.3" 155 | resolved "https://registry.yarnpkg.com/express/-/express-4.17.3.tgz#f6c7302194a4fb54271b73a1fe7a06478c8f85a1" 156 | integrity sha512-yuSQpz5I+Ch7gFrPCk4/c+dIBKlQUxtgwqzph132bsT6qhuzss6I8cLJQz7B3rFblzd6wtcI0ZbGltH/C4LjUg== 157 | dependencies: 158 | accepts "~1.3.8" 159 | array-flatten "1.1.1" 160 | body-parser "1.19.2" 161 | content-disposition "0.5.4" 162 | content-type "~1.0.4" 163 | cookie "0.4.2" 164 | cookie-signature "1.0.6" 165 | debug "2.6.9" 166 | depd "~1.1.2" 167 | encodeurl "~1.0.2" 168 | escape-html "~1.0.3" 169 | etag "~1.8.1" 170 | finalhandler "~1.1.2" 171 | fresh "0.5.2" 172 | merge-descriptors "1.0.1" 173 | methods "~1.1.2" 174 | on-finished "~2.3.0" 175 | parseurl "~1.3.3" 176 | path-to-regexp "0.1.7" 177 | proxy-addr "~2.0.7" 178 | qs "6.9.7" 179 | range-parser "~1.2.1" 180 | safe-buffer "5.2.1" 181 | send "0.17.2" 182 | serve-static "1.14.2" 183 | setprototypeof "1.2.0" 184 | statuses "~1.5.0" 185 | type-is "~1.6.18" 186 | utils-merge "1.0.1" 187 | vary "~1.1.2" 188 | 189 | fast-safe-stringify@^2.0.7: 190 | version "2.1.1" 191 | resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" 192 | integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== 193 | 194 | finalhandler@~1.1.2: 195 | version "1.1.2" 196 | resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" 197 | integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== 198 | dependencies: 199 | debug "2.6.9" 200 | encodeurl "~1.0.2" 201 | escape-html "~1.0.3" 202 | on-finished "~2.3.0" 203 | parseurl "~1.3.3" 204 | statuses "~1.5.0" 205 | unpipe "~1.0.0" 206 | 207 | form-data@^3.0.0: 208 | version "3.0.1" 209 | resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f" 210 | integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg== 211 | dependencies: 212 | asynckit "^0.4.0" 213 | combined-stream "^1.0.8" 214 | mime-types "^2.1.12" 215 | 216 | formidable@^1.2.2: 217 | version "1.2.6" 218 | resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.6.tgz#d2a51d60162bbc9b4a055d8457a7c75315d1a168" 219 | integrity sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ== 220 | 221 | forwarded@0.2.0: 222 | version "0.2.0" 223 | resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" 224 | integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== 225 | 226 | fresh@0.5.2: 227 | version "0.5.2" 228 | resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" 229 | integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= 230 | 231 | function-bind@^1.1.1: 232 | version "1.1.1" 233 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" 234 | integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== 235 | 236 | get-intrinsic@^1.0.2: 237 | version "1.1.1" 238 | resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6" 239 | integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q== 240 | dependencies: 241 | function-bind "^1.1.1" 242 | has "^1.0.3" 243 | has-symbols "^1.0.1" 244 | 245 | has-symbols@^1.0.1: 246 | version "1.0.3" 247 | resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" 248 | integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== 249 | 250 | has@^1.0.3: 251 | version "1.0.3" 252 | resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" 253 | integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== 254 | dependencies: 255 | function-bind "^1.1.1" 256 | 257 | http-errors@1.8.1: 258 | version "1.8.1" 259 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.8.1.tgz#7c3f28577cbc8a207388455dbd62295ed07bd68c" 260 | integrity sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g== 261 | dependencies: 262 | depd "~1.1.2" 263 | inherits "2.0.4" 264 | setprototypeof "1.2.0" 265 | statuses ">= 1.5.0 < 2" 266 | toidentifier "1.0.1" 267 | 268 | iconv-lite@0.4.24: 269 | version "0.4.24" 270 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" 271 | integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 272 | dependencies: 273 | safer-buffer ">= 2.1.2 < 3" 274 | 275 | inherits@2.0.3: 276 | version "2.0.3" 277 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 278 | integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= 279 | 280 | inherits@2.0.4, inherits@^2.0.3: 281 | version "2.0.4" 282 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 283 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 284 | 285 | ipaddr.js@1.9.1: 286 | version "1.9.1" 287 | resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" 288 | integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== 289 | 290 | lru-cache@^6.0.0: 291 | version "6.0.0" 292 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" 293 | integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== 294 | dependencies: 295 | yallist "^4.0.0" 296 | 297 | media-typer@0.3.0: 298 | version "0.3.0" 299 | resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" 300 | integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= 301 | 302 | merge-descriptors@1.0.1: 303 | version "1.0.1" 304 | resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" 305 | integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= 306 | 307 | methods@^1.1.2, methods@~1.1.2: 308 | version "1.1.2" 309 | resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" 310 | integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= 311 | 312 | mime-db@1.52.0: 313 | version "1.52.0" 314 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" 315 | integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== 316 | 317 | mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: 318 | version "2.1.35" 319 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" 320 | integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== 321 | dependencies: 322 | mime-db "1.52.0" 323 | 324 | mime@1.6.0: 325 | version "1.6.0" 326 | resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 327 | integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 328 | 329 | mime@^2.4.6: 330 | version "2.6.0" 331 | resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" 332 | integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== 333 | 334 | ms@2.0.0: 335 | version "2.0.0" 336 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 337 | integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= 338 | 339 | ms@2.1.2: 340 | version "2.1.2" 341 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" 342 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 343 | 344 | ms@2.1.3: 345 | version "2.1.3" 346 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 347 | integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 348 | 349 | negotiator@0.6.3: 350 | version "0.6.3" 351 | resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" 352 | integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== 353 | 354 | object-assign@^4: 355 | version "4.1.1" 356 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 357 | integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= 358 | 359 | object-inspect@^1.9.0: 360 | version "1.12.0" 361 | resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" 362 | integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== 363 | 364 | on-finished@~2.3.0: 365 | version "2.3.0" 366 | resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" 367 | integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= 368 | dependencies: 369 | ee-first "1.1.1" 370 | 371 | parseurl@~1.3.3: 372 | version "1.3.3" 373 | resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" 374 | integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== 375 | 376 | path-to-regexp@0.1.7: 377 | version "0.1.7" 378 | resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" 379 | integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= 380 | 381 | path@^0.12.7: 382 | version "0.12.7" 383 | resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f" 384 | integrity sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8= 385 | dependencies: 386 | process "^0.11.1" 387 | util "^0.10.3" 388 | 389 | process@^0.11.1: 390 | version "0.11.10" 391 | resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" 392 | integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI= 393 | 394 | proxy-addr@~2.0.7: 395 | version "2.0.7" 396 | resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" 397 | integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== 398 | dependencies: 399 | forwarded "0.2.0" 400 | ipaddr.js "1.9.1" 401 | 402 | qs@6.9.7: 403 | version "6.9.7" 404 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe" 405 | integrity sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw== 406 | 407 | qs@^6.9.4: 408 | version "6.10.3" 409 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" 410 | integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== 411 | dependencies: 412 | side-channel "^1.0.4" 413 | 414 | range-parser@~1.2.1: 415 | version "1.2.1" 416 | resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" 417 | integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== 418 | 419 | raw-body@2.4.3: 420 | version "2.4.3" 421 | resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.3.tgz#8f80305d11c2a0a545c2d9d89d7a0286fcead43c" 422 | integrity sha512-UlTNLIcu0uzb4D2f4WltY6cVjLi+/jEN4lgEUj3E04tpMDpUlkBo/eSn6zou9hum2VMNpCCUone0O0WeJim07g== 423 | dependencies: 424 | bytes "3.1.2" 425 | http-errors "1.8.1" 426 | iconv-lite "0.4.24" 427 | unpipe "1.0.0" 428 | 429 | readable-stream@^3.6.0: 430 | version "3.6.0" 431 | resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" 432 | integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== 433 | dependencies: 434 | inherits "^2.0.3" 435 | string_decoder "^1.1.1" 436 | util-deprecate "^1.0.1" 437 | 438 | safe-buffer@5.2.1, safe-buffer@~5.2.0: 439 | version "5.2.1" 440 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 441 | integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 442 | 443 | "safer-buffer@>= 2.1.2 < 3": 444 | version "2.1.2" 445 | resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" 446 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 447 | 448 | semver@^7.3.2: 449 | version "7.3.5" 450 | resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" 451 | integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== 452 | dependencies: 453 | lru-cache "^6.0.0" 454 | 455 | send@0.17.2: 456 | version "0.17.2" 457 | resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820" 458 | integrity sha512-UJYB6wFSJE3G00nEivR5rgWp8c2xXvJ3OPWPhmuteU0IKj8nKbG3DrjiOmLwpnHGYWAVwA69zmTm++YG0Hmwww== 459 | dependencies: 460 | debug "2.6.9" 461 | depd "~1.1.2" 462 | destroy "~1.0.4" 463 | encodeurl "~1.0.2" 464 | escape-html "~1.0.3" 465 | etag "~1.8.1" 466 | fresh "0.5.2" 467 | http-errors "1.8.1" 468 | mime "1.6.0" 469 | ms "2.1.3" 470 | on-finished "~2.3.0" 471 | range-parser "~1.2.1" 472 | statuses "~1.5.0" 473 | 474 | serve-static@1.14.2: 475 | version "1.14.2" 476 | resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.2.tgz#722d6294b1d62626d41b43a013ece4598d292bfa" 477 | integrity sha512-+TMNA9AFxUEGuC0z2mevogSnn9MXKb4fa7ngeRMJaaGv8vTwnIEkKi+QGvPt33HSnf8pRS+WGM0EbMtCJLKMBQ== 478 | dependencies: 479 | encodeurl "~1.0.2" 480 | escape-html "~1.0.3" 481 | parseurl "~1.3.3" 482 | send "0.17.2" 483 | 484 | setprototypeof@1.2.0: 485 | version "1.2.0" 486 | resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" 487 | integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== 488 | 489 | side-channel@^1.0.4: 490 | version "1.0.4" 491 | resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" 492 | integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== 493 | dependencies: 494 | call-bind "^1.0.0" 495 | get-intrinsic "^1.0.2" 496 | object-inspect "^1.9.0" 497 | 498 | spotify-web-api-node@^5.0.2: 499 | version "5.0.2" 500 | resolved "https://registry.yarnpkg.com/spotify-web-api-node/-/spotify-web-api-node-5.0.2.tgz#683669b3ccc046a5a357300f151df93a2b3539fe" 501 | integrity sha512-r82dRWU9PMimHvHEzL0DwEJrzFk+SMCVfq249SLt3I7EFez7R+jeoKQd+M1//QcnjqlXPs2am4DFsGk8/GCsrA== 502 | dependencies: 503 | superagent "^6.1.0" 504 | 505 | "statuses@>= 1.5.0 < 2", statuses@~1.5.0: 506 | version "1.5.0" 507 | resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" 508 | integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= 509 | 510 | string_decoder@^1.1.1: 511 | version "1.3.0" 512 | resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" 513 | integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== 514 | dependencies: 515 | safe-buffer "~5.2.0" 516 | 517 | superagent@^6.1.0: 518 | version "6.1.0" 519 | resolved "https://registry.yarnpkg.com/superagent/-/superagent-6.1.0.tgz#09f08807bc41108ef164cfb4be293cebd480f4a6" 520 | integrity sha512-OUDHEssirmplo3F+1HWKUrUjvnQuA+nZI6i/JJBdXb5eq9IyEQwPyPpqND+SSsxf6TygpBEkUjISVRN4/VOpeg== 521 | dependencies: 522 | component-emitter "^1.3.0" 523 | cookiejar "^2.1.2" 524 | debug "^4.1.1" 525 | fast-safe-stringify "^2.0.7" 526 | form-data "^3.0.0" 527 | formidable "^1.2.2" 528 | methods "^1.1.2" 529 | mime "^2.4.6" 530 | qs "^6.9.4" 531 | readable-stream "^3.6.0" 532 | semver "^7.3.2" 533 | 534 | toidentifier@1.0.1: 535 | version "1.0.1" 536 | resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" 537 | integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== 538 | 539 | type-is@~1.6.18: 540 | version "1.6.18" 541 | resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" 542 | integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== 543 | dependencies: 544 | media-typer "0.3.0" 545 | mime-types "~2.1.24" 546 | 547 | unpipe@1.0.0, unpipe@~1.0.0: 548 | version "1.0.0" 549 | resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 550 | integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= 551 | 552 | util-deprecate@^1.0.1: 553 | version "1.0.2" 554 | resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" 555 | integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= 556 | 557 | util@^0.10.3: 558 | version "0.10.4" 559 | resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" 560 | integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A== 561 | dependencies: 562 | inherits "2.0.3" 563 | 564 | utils-merge@1.0.1: 565 | version "1.0.1" 566 | resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 567 | integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= 568 | 569 | vary@^1, vary@~1.1.2: 570 | version "1.1.2" 571 | resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" 572 | integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= 573 | 574 | yallist@^4.0.0: 575 | version "4.0.0" 576 | resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" 577 | integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== 578 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "regions": ["iad1"], 3 | "builds": [ 4 | { "src": "server/app.js", "use": "@vercel/node" }, 5 | { 6 | "src": "client/package.json", 7 | "use": "@vercel/static-build", 8 | "config": { "distDir": "build" } 9 | } 10 | ], 11 | "env": { 12 | "SPOTIFY_CLIENT_ID": "@spotify_client_id", 13 | "SPOTIFY_CLIENT_SECRET": "@spotify_client_secret", 14 | "CLEANIFY_BACKEND_URL": "@cleanify_backend_url", 15 | "CLEANIFY_FRONTEND_URL": "@cleanify_frontend_url" 16 | }, 17 | "routes": [ 18 | { 19 | "src": "/api/(.*)", 20 | "headers": { "cache-control": "s-maxage=0" }, 21 | "dest": "server/app.js" 22 | }, 23 | { 24 | "src": "/static/(.*)", 25 | "headers": { "cache-control": "s-maxage=31536000, immutable" }, 26 | "dest": "client/static/$1" 27 | }, 28 | { "src": "/favicon.ico", "dest": "client/favicon.ico" }, 29 | { 30 | "src": "/asset-manifest.json", 31 | "dest": "client/asset-manifest.json" 32 | }, 33 | { 34 | "src": "/precache-manifest.(.*)", 35 | "dest": "client/precache-manifest.$1" 36 | }, 37 | { "src": "/manifest.json", "dest": "client/manifest.json" }, 38 | { 39 | "src": "/service-worker.js", 40 | "headers": { "cache-control": "s-maxage=0" }, 41 | "dest": "client/service-worker.js" 42 | }, 43 | { "src": "/(.*)", "dest": "client/index.html" } 44 | ] 45 | } 46 | --------------------------------------------------------------------------------