├── .eslintrc ├── .gitignore ├── README.md ├── api ├── authorize.js ├── fetch.js ├── refresh.js ├── token.js ├── unsave.js └── username.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.jsx ├── actions │ └── index.js ├── assets │ ├── images │ │ ├── preview.png │ │ └── saveddit.svg │ ├── main.css │ └── tailwind.css ├── components │ ├── BackButton.jsx │ ├── ContentHeader.jsx │ ├── NavigationTriplet.jsx │ ├── PaginationItem.jsx │ ├── PaginationNavigation.jsx │ ├── ProtectedRoute.jsx │ ├── SavedLinkListItem.jsx │ ├── Search.jsx │ └── SortingDropdown.jsx ├── containers │ ├── dashboard │ │ ├── Dashboard.jsx │ │ ├── content │ │ │ ├── Content.jsx │ │ │ ├── allPosts │ │ │ │ └── AllLinks.jsx │ │ │ ├── allSubreddits │ │ │ │ ├── AllSubreddits.jsx │ │ │ │ └── components │ │ │ │ │ ├── AnchorNavigation.jsx │ │ │ │ │ └── SubredditListItem.jsx │ │ │ ├── filterByNSFW │ │ │ │ └── FilterByNSFW.jsx │ │ │ └── filterBySubreddit │ │ │ │ └── FilterBySubreddit.jsx │ │ └── sidebar │ │ │ ├── SideBar.jsx │ │ │ └── components │ │ │ ├── CollapseExpandButton.jsx │ │ │ ├── ExportAsXlsSelect.jsx │ │ │ ├── RefreshButton.jsx │ │ │ ├── SideBarNavigation.jsx │ │ │ ├── SignOutButton.jsx │ │ │ └── UserInfo.jsx │ ├── homepage │ │ ├── Welcome.jsx │ │ └── components │ │ │ ├── Login.jsx │ │ │ └── Reset.jsx │ └── loadingScreen │ │ └── LoadingScreen.jsx ├── context │ └── componentContext.js ├── index.js ├── index.scss ├── logo.svg ├── redux │ ├── actionTypes.js │ ├── actions.js │ ├── localStorage.js │ ├── reducers │ │ ├── index.js │ │ ├── saved.js │ │ └── user.js │ └── store.js ├── serviceWorker.js └── setupTests.js └── tailwind.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@imaginary-cloud/react"], 3 | "parserOptions": { 4 | "ecmaFeatures": { 5 | "jsx": true 6 | } 7 | }, 8 | "plugins": ["react"], 9 | "parser": "babel-eslint" 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | .vscode 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | .vercel 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **Saveddit - Reddit management tool.** 2 | 3 | Frequent reddit browser? Find yourself saving many posts/links/articles? Frustrated that Reddit does not offer the option to search through saved posts? Then Saveddit is just the tool for you. Built and designed to be blazingly fast, and offer features that Reddit just will not do. Search by title, sort by various criteria and filter by subreddit if you so wish to, all at the click of a mouse button. 4 | 5 | # [Try it out for yourself](https://saveddit.vercel.app/) 6 | 7 | ### `Privacy Statement` 8 | Saveddit is a web application built on top of react and makes use of serverless functions to authorize and fetch all the necessary data from the Reddit API. At no point of the process is your data being acquired (for any means other than to display it) or stored anywhere remote. Everything is stored locally (localStorage) on your browser, and managed with Redux. No external database, no tracking, no analytics. It is truly free to use and collects no data, see for yourself in the included source code. 9 | 10 | ### `Built With` 11 | 12 | ReactJS | Redux | TailwindCSS | Vercel 13 | 14 | ### `FAQ` 15 | #### **Q: Are you storing, tracking or selling any data?** 16 | A: No. Nothing is stored or being sent to a remote database. All of your information is stored locally in your browser. 17 |
18 | #### **Q: Can I run this on my own server?** 19 | A: Yes. The project is open source. Make sure to setup the necessary environment variables (Client ID & Secret). 20 |
21 | #### **Q: Why are you only fetching 947 posts even though I have many more?** 22 | A: That is unfortunately the limitation of the Reddit API, it only allows to fetch said amount of latest saved posts. If you unsave a couple posts you will notice that older saved posts have appeared once again. 23 |
24 | #### **Q: It doesn't work (anymore)?** 25 | A: Most likely an issue with localStorage, try clearing it and giving it another go. If that doesn't fix it try it in incognito mode and if that doesn't fix it please report the issue on this repository. 26 |
27 | 28 | ### `Features` 29 | - [x] Authorization & Fetching Saved Posts 30 | - [x] Filter By Subreddit 31 | - [x] Search By Title 32 | - [x] Sort By: Latest Saved, A-z, z-A, New-Old, Old-New, Popularity 33 | - [x] Unsave Directly From Saveddit 34 | - [x] Export Excel Sheet 35 | - [x] Data Stored Locally (LocalStorage API) 36 | - [x] Faster than Reddit could ever be 37 | - [ ] Animations 38 | - [ ] Mobile UI & Customizability 39 | - [ ] Sharing Options 40 | - [ ] More Filtering Options 41 | - [ ] Various Export Formats 42 | - [ ] Preview Post Comments & Contents 43 | - [ ] Easter Egg 44 | - [ ] Ability to give feedback, report issues & request features. 45 | 46 | -------------------------------------------------------------------------------- /api/authorize.js: -------------------------------------------------------------------------------- 1 | const { default: fetch } = require('node-fetch') 2 | 3 | module.exports = async (req, res) => { 4 | const { seed } = req.body 5 | const redirectUri = 6 | process.env.NODE_ENV === 'production' 7 | ? 'https://saveddit.vercel.app/loading' 8 | : 'http://localhost:3000/loading' 9 | 10 | try { 11 | const response = await fetch( 12 | `https://www.reddit.com/api/v1/authorize?client_id=${process.env.CLIENT_ID}&response_type=code&state=${seed}&redirect_uri=${redirectUri}&duration=permanent&scope=vote history identity read save`, 13 | ) 14 | return res.json({ url: response.url }) 15 | } catch (error) { 16 | return res.json({ error }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /api/fetch.js: -------------------------------------------------------------------------------- 1 | const { qs } = require('url-parse') 2 | const { default: fetch } = require('node-fetch') 3 | 4 | const minifyReponse = (array) => 5 | array.map( 6 | ({ 7 | author, 8 | archived, 9 | created_utc: createdUtc, 10 | domain, 11 | id, 12 | num_comments: numComments, 13 | over_18: over18, 14 | permalink, 15 | score, 16 | subreddit_name_prefixed: subredditNamePrefixed, 17 | subreddit, 18 | title, 19 | url, 20 | }) => ({ 21 | author, 22 | archived, 23 | createdUtc, 24 | domain, 25 | id, 26 | numComments, 27 | over18, 28 | permalink, 29 | score, 30 | subredditNamePrefixed, 31 | subreddit, 32 | title, 33 | url, 34 | }), 35 | ) 36 | 37 | module.exports = async (req, res) => { 38 | const { token, username, afterListing } = JSON.parse(req.body) 39 | const config = { 40 | method: 'GET', 41 | headers: { 42 | Authorization: `Bearer ${token}`, 43 | }, 44 | } 45 | 46 | try { 47 | const response = await fetch( 48 | `https://oauth.reddit.com/user/${username.toLowerCase()}/saved/?limit=100${ 49 | afterListing ? `&after=${afterListing}` : '' 50 | }`, 51 | config, 52 | ) 53 | const { 54 | data: { dist, after, children, before }, 55 | } = await response.json() 56 | return res.json({ 57 | dist, 58 | after, 59 | before, 60 | links: await minifyReponse(children.map((a) => a.data)), 61 | }) 62 | } catch (error) { 63 | return res.json(error) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /api/refresh.js: -------------------------------------------------------------------------------- 1 | const { qs } = require('url-parse') 2 | const { default: fetch } = require('node-fetch') 3 | 4 | module.exports = async (req, res) => { 5 | const { token } = req.query 6 | const data = qs.stringify({ 7 | grant_type: 'refresh_token', 8 | refresh_token: token, 9 | }) 10 | const config = { 11 | method: 'post', 12 | headers: { 13 | 'Content-Type': 'application/x-www-form-urlencoded', 14 | Authorization: `Basic ${process.env.BASIC_AUTH}`, 15 | }, 16 | body: data, 17 | } 18 | try { 19 | const response = await fetch( 20 | 'https://www.reddit.com/api/v1/access_token', 21 | config, 22 | ) 23 | const result = await response.json() 24 | return res.json(result) 25 | } catch (error) { 26 | return res.json(error) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /api/token.js: -------------------------------------------------------------------------------- 1 | const { qs } = require('url-parse') 2 | const { default: fetch } = require('node-fetch') 3 | 4 | module.exports = async (req, res) => { 5 | const { code } = req.query 6 | const redirectUri = 7 | process.env.NODE_ENV === 'production' 8 | ? 'https://saveddit.vercel.app/loading' 9 | : 'http://localhost:3000/loading' 10 | const data = qs.stringify({ 11 | grant_type: 'authorization_code', 12 | code, 13 | redirect_uri: redirectUri, 14 | }) 15 | const config = { 16 | method: 'post', 17 | headers: { 18 | 'Content-Type': 'application/x-www-form-urlencoded', 19 | Authorization: `Basic ${process.env.BASIC_AUTH}`, 20 | }, 21 | body: data, 22 | } 23 | try { 24 | const response = await fetch( 25 | 'https://www.reddit.com/api/v1/access_token', 26 | config, 27 | ) 28 | const result = await response.json() 29 | return res.json(result) 30 | } catch (error) { 31 | return res.json(error) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /api/unsave.js: -------------------------------------------------------------------------------- 1 | const { default: fetch } = require('node-fetch') 2 | 3 | module.exports = async (req, res) => { 4 | const { body } = req 5 | const { id, token } = JSON.parse(body) 6 | const config = { 7 | method: 'POST', 8 | headers: { 9 | Authorization: `Bearer ${token}`, 10 | }, 11 | } 12 | 13 | try { 14 | const response = await fetch( 15 | `https://oauth.reddit.com/api/unsave?id=t3_${id}`, 16 | config, 17 | ) 18 | const data = await response.json() 19 | return res.json(data) 20 | } catch (error) { 21 | return res.json(error) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /api/username.js: -------------------------------------------------------------------------------- 1 | const { default: fetch } = require('node-fetch') 2 | 3 | module.exports = async (req, res) => { 4 | const { body } = req 5 | const { token } = JSON.parse(body) 6 | const config = { 7 | method: 'GET', 8 | headers: { 9 | Authorization: `Bearer ${token}`, 10 | }, 11 | } 12 | try { 13 | const response = await fetch(`https://oauth.reddit.com/api/v1/me`, config) 14 | const data = await response.json() 15 | return res.json(data) 16 | } catch (error) { 17 | return res.json(error) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saveddit", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-svg-core": "^1.2.32", 7 | "@fortawesome/free-brands-svg-icons": "^5.15.1", 8 | "@fortawesome/free-solid-svg-icons": "^5.15.1", 9 | "@fortawesome/react-fontawesome": "^0.1.12", 10 | "@testing-library/jest-dom": "^4.2.4", 11 | "@testing-library/react": "^9.3.2", 12 | "@testing-library/user-event": "^7.1.2", 13 | "axios": "^0.20.0", 14 | "date-fns": "^2.16.1", 15 | "dexie": "^3.0.2", 16 | "dotenv": "^8.2.0", 17 | "export-from-json": "^1.3.4", 18 | "lodash": "^4.17.20", 19 | "lodash.throttle": "^4.1.1", 20 | "node-fetch": "^2.6.0", 21 | "node-sass": "^4.14.1", 22 | "postcss": "^8.1.7", 23 | "prop-types": "^15.7.2", 24 | "react": "^16.13.1", 25 | "react-detect-offline": "^2.4.0", 26 | "react-dom": "^16.13.1", 27 | "react-redux": "^7.2.1", 28 | "react-router-dom": "^5.2.0", 29 | "react-router-hash-link": "^2.2.2", 30 | "react-scripts": "3.4.3", 31 | "react-spring": "^8.0.27", 32 | "react-text-truncate": "^0.16.0", 33 | "redux": "^4.0.5", 34 | "redux-persist": "^6.0.0", 35 | "redux-persist-indexeddb-storage": "^1.0.4", 36 | "redux-storage": "^4.1.2", 37 | "redux-storage-engine-indexed-db": "^1.0.0", 38 | "reselect": "^4.0.0", 39 | "url-parse": "^1.4.7", 40 | "uuid": "^8.3.1" 41 | }, 42 | "scripts": { 43 | "start": "npm run watch:css && react-scripts start", 44 | "build": "npm run build:css && react-scripts build", 45 | "test": "react-scripts test", 46 | "eject": "react-scripts eject", 47 | "build:css": "postcss src/assets/tailwind.css -o src/assets/main.css", 48 | "watch:css": "postcss src/assets/tailwind.css -o src/assets/main.css", 49 | "lint": "eslint ./ --ignore-path .gitignore", 50 | "lint:fix": "npm run lint -- --fix", 51 | "format": "prettier --write \"{,!(node_modules)/**/}*.jsx*\"" 52 | }, 53 | "eslintConfig": { 54 | "extends": "@imaginary-cloud/react" 55 | }, 56 | "prettier": "@imaginary-cloud/prettier-config", 57 | "browserslist": { 58 | "production": [ 59 | ">0.2%", 60 | "not dead", 61 | "not op_mini all" 62 | ], 63 | "development": [ 64 | "last 1 chrome version", 65 | "last 1 firefox version", 66 | "last 1 safari version" 67 | ] 68 | }, 69 | "devDependencies": { 70 | "@imaginary-cloud/eslint-config-react": "^1.0.1", 71 | "@imaginary-cloud/prettier-config": "^1.0.0", 72 | "autoprefixer": "^9.8.6", 73 | "eslint": "^6.6.0", 74 | "eslint-config-airbnb": "^18.2.1", 75 | "eslint-config-prettier": "^6.15.0", 76 | "eslint-plugin-import": "^2.22.1", 77 | "eslint-plugin-jsx-a11y": "^6.4.1", 78 | "eslint-plugin-prettier": "^3.1.4", 79 | "eslint-plugin-react": "^7.21.5", 80 | "eslint-plugin-react-hooks": "^2.5.1", 81 | "postcss-cli": "^7.1.1", 82 | "prettier": "^2.1.2", 83 | "tailwindcss": "^1.7.6" 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwindcss = require('tailwindcss') 2 | module.exports = { 3 | plugins: [tailwindcss('./tailwind.js'), require('autoprefixer')], 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aleksasgrodis/saveddit/ed0eab9fa0c991deb31479b87b5b6b8e6d3cb826/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 28 | 32 | Saveddit - Reddit Manager 33 | 34 | 35 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aleksasgrodis/saveddit/ed0eab9fa0c991deb31479b87b5b6b8e6d3cb826/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aleksasgrodis/saveddit/ed0eab9fa0c991deb31479b87b5b6b8e6d3cb826/public/logo512.png -------------------------------------------------------------------------------- /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 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.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 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom' 3 | import Dashboard from './containers/dashboard/Dashboard' 4 | import Welcome from './containers/homepage/Welcome' 5 | import LoadingScreen from './containers/loadingScreen/LoadingScreen' 6 | import ProtectedRoute from './components/ProtectedRoute' 7 | 8 | function App() { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | export default App 25 | -------------------------------------------------------------------------------- /src/actions/index.js: -------------------------------------------------------------------------------- 1 | export const addToken = (token) => ({ 2 | type: 'ADD_TOKEN', 3 | token, 4 | }) 5 | 6 | export const addUsername = (username) => ({ 7 | type: 'ADD_USERNAME', 8 | username, 9 | }) 10 | 11 | export const addCode = (code) => ({ 12 | type: 'ADD_CODE', 13 | code, 14 | }) 15 | 16 | export const addSaved = (data) => ({ 17 | type: 'ADD_SAVED', 18 | data, 19 | }) 20 | 21 | export const getSaved = () => ({ 22 | type: 'GET_SAVED', 23 | }) 24 | -------------------------------------------------------------------------------- /src/assets/images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aleksasgrodis/saveddit/ed0eab9fa0c991deb31479b87b5b6b8e6d3cb826/src/assets/images/preview.png -------------------------------------------------------------------------------- /src/assets/images/saveddit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 12 | Saveddit 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/assets/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | 3 | @import "tailwindcss/components"; 4 | 5 | @import "tailwindcss/utilities"; 6 | -------------------------------------------------------------------------------- /src/components/BackButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useHistory } from 'react-router-dom' 3 | 4 | function BackButton() { 5 | const history = useHistory() 6 | return ( 7 | 14 | ) 15 | } 16 | 17 | export default BackButton 18 | -------------------------------------------------------------------------------- /src/components/ContentHeader.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import PropTypes from 'prop-types' 3 | import BackButton from './BackButton' 4 | import Search from './Search' 5 | import SortingDropdown from './SortingDropdown' 6 | import { ComponentContext } from '../context/componentContext' 7 | 8 | function ContentHeader({ count, withHistory, withSort, ...props }) { 9 | const { headingTitle, headingSort } = useContext(ComponentContext) 10 | return ( 11 |
12 |
13 |
14 | {withHistory && } 15 |

16 | {headingTitle || 'All Posts'} 17 |

18 | 19 | {headingSort && } 20 |
21 |
22 |
23 | ) 24 | } 25 | 26 | ContentHeader.propTypes = { 27 | count: PropTypes.number, 28 | withHistory: PropTypes.bool, 29 | withSort: PropTypes.bool, 30 | } 31 | 32 | export default ContentHeader 33 | -------------------------------------------------------------------------------- /src/components/NavigationTriplet.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import PaginationItem from './PaginationItem' 4 | 5 | function PaginationTriplet({ currentPage, total }) { 6 | return ( 7 | <> 8 | {currentPage > 4 ? ( 9 | 10 | ... 11 | 12 | ) : null} 13 | {currentPage > 4 ? ( 14 | 15 | ) : null} 16 | 17 | {currentPage < total - 3 ? ( 18 | 19 | ) : null} 20 | {currentPage < total - 3 ? ( 21 | 22 | ... 23 | 24 | ) : null} 25 | 26 | ) 27 | } 28 | 29 | PaginationTriplet.propTypes = { 30 | currentPage: PropTypes.number, 31 | total: PropTypes.number, 32 | } 33 | 34 | export default PaginationTriplet 35 | -------------------------------------------------------------------------------- /src/components/PaginationItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { useDispatch } from 'react-redux' 4 | import { useLocation } from 'react-router-dom/cjs/react-router-dom.min' 5 | import { useHistory } from 'react-router-dom' 6 | import { loadNumberedPage } from '../redux/actions' 7 | 8 | function PaginationItem({ page, currentPage }) { 9 | const dispatch = useDispatch() 10 | const history = useHistory() 11 | const { pathname } = useLocation() 12 | const path = pathname.split('/').slice(1, 3).join('/') 13 | return ( 14 | 26 | ) 27 | } 28 | PaginationItem.propTypes = { 29 | page: PropTypes.number, 30 | currentPage: PropTypes.number, 31 | } 32 | 33 | export default PaginationItem 34 | -------------------------------------------------------------------------------- /src/components/PaginationNavigation.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | faChevronLeft, 3 | faChevronRight, 4 | } from '@fortawesome/free-solid-svg-icons' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import React from 'react' 7 | import { useDispatch } from 'react-redux' 8 | import PropTypes from 'prop-types' 9 | import { useLocation, useHistory } from 'react-router-dom' 10 | import { loadNumberedPage } from '../redux/actions' 11 | import PaginationItem from './PaginationItem' 12 | import PaginationTriplet from './NavigationTriplet' 13 | 14 | function PaginationNavigation({ total, currentPage }) { 15 | const dispatch = useDispatch() 16 | const history = useHistory() 17 | const { pathname } = useLocation() 18 | const path = pathname.split('/').slice(1, 3).join('/') 19 | const pageNumbers = new Array(total).fill(0) 20 | if (total <= 1) { 21 | return null 22 | } 23 | if (total <= 10) { 24 | return ( 25 |
26 | 60 |
61 | ) 62 | } 63 | 64 | if (total > 10) { 65 | return ( 66 |
67 | 110 |
111 | ) 112 | } 113 | } 114 | 115 | PaginationNavigation.propTypes = { 116 | total: PropTypes.number, 117 | currentPage: PropTypes.number, 118 | } 119 | 120 | export default PaginationNavigation 121 | -------------------------------------------------------------------------------- /src/components/ProtectedRoute.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { Redirect, Route } from 'react-router-dom' 4 | import PropTypes from 'prop-types' 5 | 6 | const ProtectedRoute = ({ component: Component, ...rest }) => { 7 | const { name, token } = useSelector((state) => state.user) 8 | return ( 9 | 12 | name && token ? : 13 | } 14 | /> 15 | ) 16 | } 17 | 18 | ProtectedRoute.propTypes = { 19 | component: PropTypes.elementType, 20 | } 21 | 22 | export default ProtectedRoute 23 | -------------------------------------------------------------------------------- /src/components/SavedLinkListItem.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | faRedditAlien, 3 | faRedditSquare, 4 | } from '@fortawesome/free-brands-svg-icons' 5 | import { 6 | faArrowUp, 7 | faCalendarAlt, 8 | faCommentAlt, 9 | faLink, 10 | faTrashAlt, 11 | } from '@fortawesome/free-solid-svg-icons' 12 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 13 | import { format, fromUnixTime } from 'date-fns' 14 | import React from 'react' 15 | import { useDispatch, useSelector } from 'react-redux' 16 | import TextTruncate from 'react-text-truncate' 17 | import PropTypes from 'prop-types' 18 | import { unsavePost } from '../redux/actions' 19 | 20 | function SavedLinkListItem({ 21 | title, 22 | url, 23 | permalink, 24 | score, 25 | numComments, 26 | createdUtc, 27 | domain, 28 | over18, 29 | subredditNamePrefixed, 30 | id, 31 | }) { 32 | const { token } = useSelector((state) => state.user) 33 | const dispatch = useDispatch() 34 | const unsave = (postID) => { 35 | fetch('/api/unsave', { 36 | method: 'post', 37 | body: JSON.stringify({ 38 | id: postID, 39 | token, 40 | }), 41 | }) 42 | .then((res) => res.json()) 43 | .then(() => { 44 | dispatch(unsavePost({ id })) 45 | }) 46 | .catch((err) => console.log(err)) 47 | } 48 | return ( 49 |
50 |
51 |
52 | 53 | 54 | {score > 100000 ? '100K+' : score} 55 | 56 |
57 |
58 | 59 | {numComments} 60 |
61 |
62 |
63 |
64 |
65 |
66 |

67 | 71 | 78 | {subredditNamePrefixed} 79 | 80 |

81 | {over18 && ( 82 | 86 | NSFW 87 | 88 | )} 89 |
90 |
91 | 98 | 104 | 105 |
106 |
107 |
108 |
109 | 113 | 114 | Posted on {format(fromUnixTime(createdUtc), 'd MMM yyy')} 115 | 116 |
117 |
118 | {domain} 119 |
120 |
121 |
122 |
123 | 130 | 136 | 137 | 144 | 150 | 151 | 159 |
160 |
161 | ) 162 | } 163 | 164 | SavedLinkListItem.propTypes = { 165 | title: PropTypes.string, 166 | url: PropTypes.string, 167 | permalink: PropTypes.string, 168 | score: PropTypes.number, 169 | numComments: PropTypes.number, 170 | createdUtc: PropTypes.number, 171 | domain: PropTypes.string, 172 | over18: PropTypes.bool, 173 | subredditNamePrefixed: PropTypes.string, 174 | id: PropTypes.string, 175 | } 176 | 177 | export default SavedLinkListItem 178 | -------------------------------------------------------------------------------- /src/components/Search.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | import { setSearchResults } from '../redux/actions' 4 | import { ComponentContext } from '../context/componentContext' 5 | 6 | function Search() { 7 | const dispatch = useDispatch() 8 | const { 9 | customSearch, 10 | subredditSearchValue, 11 | setSubredditSearchValue, 12 | searchValue, 13 | setSearchValue, 14 | } = useContext(ComponentContext) 15 | 16 | useEffect(() => { 17 | dispatch(setSearchResults({ value: searchValue })) 18 | }, [searchValue, dispatch]) 19 | 20 | return ( 21 |
22 | 25 | customSearch 26 | ? setSubredditSearchValue(e.target.value) 27 | : setSearchValue(e.target.value) 28 | } 29 | className="appearance-none block w-full bg-white text-gray-700 border border-gray-200 shadow-md rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-blue-500" 30 | id="search-input" 31 | type="text" 32 | placeholder={ 33 | customSearch ? 'Search for subreddit title..' : 'Search for title..' 34 | } 35 | /> 36 |
37 | ) 38 | } 39 | 40 | export default Search 41 | -------------------------------------------------------------------------------- /src/components/SortingDropdown.jsx: -------------------------------------------------------------------------------- 1 | import { faChevronDown } from '@fortawesome/free-solid-svg-icons' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import React from 'react' 4 | import { useDispatch } from 'react-redux' 5 | import { setSortingMethod } from '../redux/actions' 6 | 7 | function SortingDropdown() { 8 | const dispatch = useDispatch() 9 | return ( 10 |
11 |
12 | 30 |
31 | 32 |
33 |
34 |
35 | ) 36 | } 37 | 38 | export default SortingDropdown 39 | -------------------------------------------------------------------------------- /src/containers/dashboard/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { useSpring, animated } from 'react-spring' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | import { updateToken } from '../../redux/actions' 5 | import Content from './content/Content' 6 | import SideBar from './sidebar/SideBar' 7 | import ContentHeader from '../../components/ContentHeader' 8 | import { ComponentContext } from '../../context/componentContext' 9 | 10 | function Dashboard() { 11 | const { 12 | user: { expires, refresh_token: refreshToken }, 13 | } = useSelector((state) => state) 14 | const dispatch = useDispatch() 15 | const [sidebarOpen, setSidebarOpen] = React.useState(true) 16 | const [headingTitle, setHeadingTitle] = useState('All Posts') 17 | const [headingSort, setHeadingSort] = useState(true) 18 | const [customSearch, setCustomSearch] = useState(false) 19 | const [searchValue, setSearchValue] = useState('') 20 | const [subredditSearchValue, setSubredditSearchValue] = useState('') 21 | const contextValues = { 22 | headingTitle, 23 | setHeadingTitle, 24 | headingSort, 25 | setHeadingSort, 26 | subredditSearchValue, 27 | setSubredditSearchValue, 28 | customSearch, 29 | setCustomSearch, 30 | searchValue, 31 | setSearchValue, 32 | } 33 | 34 | const rightMenuAnimation = useSpring({ 35 | width: sidebarOpen ? '220px' : '80px', 36 | transform: sidebarOpen ? `translateX(0)` : `translateX(100%)`, 37 | }) 38 | 39 | useEffect(() => { 40 | const date = Date.now() 41 | if (date > expires) { 42 | fetch(`/api/refresh?token=${refreshToken}`) 43 | .then((res) => res.json()) 44 | .then((data) => { 45 | dispatch( 46 | updateToken({ token: data.access_token, expires: date + 3600000 }), 47 | ) 48 | }) 49 | .catch((err) => console.log(err)) 50 | } 51 | return () => {} 52 | }, [dispatch, refreshToken, expires]) 53 | 54 | return ( 55 | 56 |
57 | 61 | 62 | 63 |
64 | 65 | 66 |
67 |
68 |
69 | ) 70 | } 71 | 72 | export default Dashboard 73 | -------------------------------------------------------------------------------- /src/containers/dashboard/content/Content.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, Switch } from 'react-router-dom' 3 | import AllLinks from './allPosts/AllLinks' 4 | import AllSubreddits from './allSubreddits/AllSubreddits' 5 | import FilterByNSFW from './filterByNSFW/FilterByNSFW' 6 | import FilterBySubreddit from './filterBySubreddit/FilterBySubreddit' 7 | 8 | function Content() { 9 | return ( 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | ) 33 | } 34 | 35 | export default Content 36 | -------------------------------------------------------------------------------- /src/containers/dashboard/content/allPosts/AllLinks.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react' 2 | import { batch, useDispatch, useSelector } from 'react-redux' 3 | import { useParams } from 'react-router-dom' 4 | import { loadNumberedPage, setSearchResults } from '../../../../redux/actions' 5 | import PaginationNavigation from '../../../../components/PaginationNavigation' 6 | import SavedLinkListItem from '../../../../components/SavedLinkListItem' 7 | import { ComponentContext } from '../../../../context/componentContext' 8 | 9 | function AllLinks() { 10 | const dispatch = useDispatch() 11 | const { setSearchValue } = useContext(ComponentContext) 12 | const { page } = useParams() 13 | 14 | useEffect(() => { 15 | setSearchValue('') 16 | batch(() => { 17 | dispatch(setSearchResults({ value: '' })) 18 | dispatch(loadNumberedPage({ page: page * 1 || 1 })) 19 | }) 20 | }, [dispatch]) 21 | 22 | const { pageResults, currentPage, searchPages } = useSelector( 23 | (state) => state.saved, 24 | ) 25 | return ( 26 |
27 |
28 | {pageResults.map((link) => ( 29 | 30 | ))} 31 |
32 | 33 |
34 | ) 35 | } 36 | 37 | export default AllLinks 38 | -------------------------------------------------------------------------------- /src/containers/dashboard/content/allSubreddits/AllSubreddits.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useMemo, useState } from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { createSelector } from 'reselect' 4 | import AnchorNavigation from './components/AnchorNavigation' 5 | import SubredditListItem from './components/SubredditListItem' 6 | import { ComponentContext } from '../../../../context/componentContext' 7 | 8 | const linksSelector = (state) => state.saved.links 9 | const subredditSelector = createSelector(linksSelector, (links) => 10 | links.map((link) => link.subreddit), 11 | ) 12 | 13 | function AllSubreddits() { 14 | const [searchResults, setSearchResults] = useState(null) 15 | const duplicateSubreddits = useSelector(subredditSelector) 16 | const subreddits = [...new Set(duplicateSubreddits)].sort((a, b) => 17 | a.localeCompare(b), 18 | ) 19 | const copy = useMemo(() => [...duplicateSubreddits], [duplicateSubreddits]) 20 | const { 21 | setHeadingTitle, 22 | setHeadingSort, 23 | subredditSearchValue, 24 | setCustomSearch, 25 | } = useContext(ComponentContext) 26 | 27 | useEffect(() => { 28 | setHeadingTitle('All Subreddits') 29 | setHeadingSort(false) 30 | setCustomSearch(true) 31 | return () => { 32 | setHeadingTitle(null) 33 | setHeadingSort(true) 34 | setCustomSearch(false) 35 | } 36 | }, []) 37 | 38 | const alphabet = [ 39 | 'A', 40 | 'B', 41 | 'C', 42 | 'D', 43 | 'E', 44 | 'F', 45 | 'G', 46 | 'H', 47 | 'I', 48 | 'J', 49 | 'K', 50 | 'L', 51 | 'M', 52 | 'N', 53 | 'O', 54 | 'P', 55 | 'Q', 56 | 'R', 57 | 'S', 58 | 'T', 59 | 'U', 60 | 'V', 61 | 'W', 62 | 'X', 63 | 'Y', 64 | 'Z', 65 | '0-9', 66 | ] 67 | const sortedByLetter = alphabet.map((letter) => { 68 | if (letter === '0-9') { 69 | return [letter, [...subreddits.filter((sub) => /\d/.test(sub.charAt(0)))]] 70 | } 71 | return [ 72 | letter, 73 | [...subreddits.filter((sub) => sub.toUpperCase().charAt(0) === letter)], 74 | ] 75 | }) 76 | useEffect(() => { 77 | const alphabetEffect = [ 78 | 'A', 79 | 'B', 80 | 'C', 81 | 'D', 82 | 'E', 83 | 'F', 84 | 'G', 85 | 'H', 86 | 'I', 87 | 'J', 88 | 'K', 89 | 'L', 90 | 'M', 91 | 'N', 92 | 'O', 93 | 'P', 94 | 'Q', 95 | 'R', 96 | 'S', 97 | 'T', 98 | 'U', 99 | 'V', 100 | 'W', 101 | 'X', 102 | 'Y', 103 | 'Z', 104 | '0-9', 105 | ] 106 | if (copy) { 107 | const sortedSubreddits = [...new Set(copy)].sort((a, b) => 108 | a.localeCompare(b), 109 | ) 110 | setSearchResults( 111 | alphabetEffect.map((letter) => [ 112 | letter, 113 | [ 114 | ...sortedSubreddits 115 | .filter((sub) => 116 | sub.toLowerCase().includes(subredditSearchValue.toLowerCase()), 117 | ) 118 | .filter((link) => { 119 | if (letter === '0-9') { 120 | return /\d/.test(link.charAt(0)) 121 | } 122 | return link.toUpperCase().charAt(0) === letter 123 | }), 124 | ], 125 | ]), 126 | ) 127 | } 128 | }, [subredditSearchValue, copy]) 129 | 130 | useEffect(() => { 131 | if (window.location.hash) { 132 | const id = window.location.hash.replace('#', '') 133 | const element = document.getElementById(id) 134 | element.scrollIntoView({ behavior: 'smooth', block: 'start' }) 135 | } 136 | }, []) 137 | 138 | return ( 139 |
140 |
141 |
142 | {sortedByLetter && !subredditSearchValue.length 143 | ? sortedByLetter.map((letter) => { 144 | if (letter[1].length) { 145 | return ( 146 |
151 |

{letter[0]}

152 |
153 | {letter[1].map((sub) => ( 154 | 155 | ))} 156 |
157 |
158 | ) 159 | } 160 | return null 161 | }) 162 | : searchResults.map((letter) => { 163 | if (letter[1].length) { 164 | return ( 165 |
170 |

{letter[0]}

171 |
172 | {letter[1].map((sub) => ( 173 | 174 | ))} 175 |
176 |
177 | ) 178 | } 179 | return null 180 | })} 181 |
182 | 183 |
184 |
185 | ) 186 | } 187 | 188 | export default AllSubreddits 189 | -------------------------------------------------------------------------------- /src/containers/dashboard/content/allSubreddits/components/AnchorNavigation.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NavHashLink } from 'react-router-hash-link' 3 | import PropTypes from 'prop-types' 4 | 5 | function AnchorNavigation({ sortedArray }) { 6 | return ( 7 |
8 | 25 |
26 | ) 27 | } 28 | 29 | AnchorNavigation.propTypes = { 30 | sortedArray: PropTypes.array, 31 | } 32 | export default AnchorNavigation 33 | -------------------------------------------------------------------------------- /src/containers/dashboard/content/allSubreddits/components/SubredditListItem.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NavLink } from 'react-router-dom' 3 | import PropTypes from 'prop-types' 4 | function SubredditListItem({ title }) { 5 | return ( 6 | 10 |
11 | {title} 12 |
13 |
14 | ) 15 | } 16 | 17 | SubredditListItem.propTypes = { 18 | title: PropTypes.string, 19 | } 20 | 21 | export default SubredditListItem 22 | -------------------------------------------------------------------------------- /src/containers/dashboard/content/filterByNSFW/FilterByNSFW.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { useHistory } from 'react-router-dom' 4 | import PaginationNavigation from '../../../../components/PaginationNavigation' 5 | import SavedLinkListItem from '../../../../components/SavedLinkListItem' 6 | import { ComponentContext } from '../../../../context/componentContext' 7 | import { 8 | resetNsfwFilter, 9 | setNsfwFilter, 10 | setSearchResults, 11 | } from '../../../../redux/actions' 12 | 13 | function FilterByNSFW() { 14 | const dispatch = useDispatch() 15 | const { pageResults, currentPage, searchPages } = useSelector( 16 | (state) => state.saved, 17 | ) 18 | const history = useHistory() 19 | const { setHeadingTitle, setSearchValue, searchValue } = useContext( 20 | ComponentContext, 21 | ) 22 | useEffect(() => { 23 | dispatch(setNsfwFilter()) 24 | dispatch(setSearchResults({ value: '' })) 25 | return () => { 26 | dispatch(resetNsfwFilter()) 27 | } 28 | }, [dispatch]) 29 | 30 | // useEffect(() => { 31 | // if (pageResults.length === 0 && searchValue !== '') { 32 | // history.go(-1) 33 | // } 34 | // return () => {} 35 | // }, [pageResults, searchValue]) 36 | 37 | useEffect(() => { 38 | setSearchValue('') 39 | setHeadingTitle('NSFW Posts') 40 | return () => { 41 | setHeadingTitle(null) 42 | } 43 | }, []) 44 | 45 | return ( 46 |
47 |
48 | {pageResults && 49 | pageResults.map((link) => ( 50 | 51 | ))} 52 |
53 | 54 |
55 | ) 56 | } 57 | 58 | export default FilterByNSFW 59 | -------------------------------------------------------------------------------- /src/containers/dashboard/content/filterBySubreddit/FilterBySubreddit.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react' 2 | import { useDispatch, useSelector, batch } from 'react-redux' 3 | import { useHistory, useParams } from 'react-router-dom' 4 | import { 5 | loadNumberedPage, 6 | setSearchResults, 7 | setSubredditFilter, 8 | } from '../../../../redux/actions' 9 | import PaginationNavigation from '../../../../components/PaginationNavigation' 10 | import SavedLinkListItem from '../../../../components/SavedLinkListItem' 11 | import { ComponentContext } from '../../../../context/componentContext' 12 | 13 | function FilterBySubreddit() { 14 | const { subreddit, page } = useParams() 15 | const dispatch = useDispatch() 16 | const history = useHistory() 17 | const { pageResults, currentPage, searchPages } = useSelector( 18 | (state) => state.saved, 19 | ) 20 | const { setSubredditSearchValue } = useContext(ComponentContext) 21 | 22 | useEffect(() => { 23 | setSubredditSearchValue('') 24 | batch(() => { 25 | dispatch(setSubredditFilter({ subreddit })) 26 | dispatch(setSearchResults({ value: '' })) 27 | dispatch(loadNumberedPage({ page: page * 1 || 1 })) 28 | }) 29 | return () => { 30 | dispatch(setSubredditFilter({ subreddit: null })) 31 | } 32 | }, [dispatch, subreddit]) 33 | 34 | // useEffect(() => { 35 | // if (pageResults.length === 0) { 36 | // history.go(-1) 37 | // } 38 | // return () => {} 39 | // }, [pageResults]) 40 | 41 | return ( 42 |
43 |
44 | {pageResults.map((link) => ( 45 | 46 | ))} 47 |
48 | 49 |
50 | ) 51 | } 52 | 53 | export default FilterBySubreddit 54 | -------------------------------------------------------------------------------- /src/containers/dashboard/sidebar/SideBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { animated } from 'react-spring' 3 | import PropTypes from 'prop-types' 4 | import CollapseExpandButton from './components/CollapseExpandButton' 5 | import ExportAsXlsSelect from './components/ExportAsXlsSelect' 6 | import RefreshButton from './components/RefreshButton' 7 | import SideBarNavigation from './components/SideBarNavigation' 8 | import SignOutButton from './components/SignOutButton' 9 | import UserInfo from './components/UserInfo' 10 | 11 | function SideBar({ isOpen, setSidebarOpen }) { 12 | return ( 13 | 18 |
19 | 20 |
21 | 22 |
23 | 24 | 25 | 26 | 27 |
28 |
29 | ) 30 | } 31 | 32 | SideBar.propTypes = { 33 | isOpen: PropTypes.bool, 34 | setSidebarOpen: PropTypes.func, 35 | } 36 | 37 | export default SideBar 38 | -------------------------------------------------------------------------------- /src/containers/dashboard/sidebar/components/CollapseExpandButton.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | faCompressArrowsAlt, 3 | faExpandArrowsAlt, 4 | } from '@fortawesome/free-solid-svg-icons' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | import React from 'react' 7 | import PropTypes from 'prop-types' 8 | 9 | function CollapseExpandButton({ isOpen, setSidebarOpen }) { 10 | return ( 11 | 24 | ) 25 | } 26 | 27 | CollapseExpandButton.propTypes = { 28 | isOpen: PropTypes.bool, 29 | setSidebarOpen: PropTypes.func, 30 | } 31 | 32 | export default CollapseExpandButton 33 | -------------------------------------------------------------------------------- /src/containers/dashboard/sidebar/components/ExportAsXlsSelect.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import exportFromJSON from 'export-from-json' 3 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 4 | import { faDownload } from '@fortawesome/free-solid-svg-icons' 5 | import { useSelector } from 'react-redux' 6 | 7 | function ExportAsXlsSelect() { 8 | const { 9 | links, 10 | subredditFilter, 11 | searchResults, 12 | pageResults, 13 | currentPage, 14 | searchPages, 15 | total, 16 | } = useSelector((state) => state.saved) 17 | const [selectValue, setSelectValue] = useState('') 18 | 19 | const exportAsXLS = (event) => { 20 | const exportType = 'xls' 21 | let fileName = 'spreadsheet' 22 | let data = [] 23 | switch (event.target.value) { 24 | case 'page': 25 | fileName = `Page${currentPage}/${searchPages}` 26 | data = pageResults 27 | exportFromJSON({ data, fileName, exportType }) 28 | break 29 | case 'subreddit': 30 | fileName = `ResultsFrom-r/${subredditFilter}` 31 | data = searchResults 32 | exportFromJSON({ data, fileName, exportType }) 33 | break 34 | case 'everything': 35 | fileName = `AllSavedPosts(${total})` 36 | data = links 37 | exportFromJSON({ data, fileName, exportType }) 38 | break 39 | default: 40 | break 41 | } 42 | setSelectValue('') 43 | } 44 | 45 | return ( 46 |
47 | 64 |
65 | 66 |
67 |
68 | ) 69 | } 70 | 71 | export default ExportAsXlsSelect 72 | -------------------------------------------------------------------------------- /src/containers/dashboard/sidebar/components/RefreshButton.jsx: -------------------------------------------------------------------------------- 1 | import { faSyncAlt } from '@fortawesome/free-solid-svg-icons' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import React from 'react' 4 | import { useDispatch, useSelector } from 'react-redux' 5 | import { useHistory } from 'react-router-dom' 6 | import { refreshSaved, updateToken } from '../../../../redux/actions' 7 | 8 | function RefreshButton() { 9 | const dispatch = useDispatch() 10 | const history = useHistory() 11 | const { refresh_token: refreshToken } = useSelector((state) => state.user) 12 | 13 | const requestRefreshToken = () => 14 | fetch(`/api/refresh?token=${refreshToken}`) 15 | .then((res) => res.json()) 16 | .then((data) => { 17 | if (data.access_token) { 18 | const expires = Date.now() + 3600000 19 | dispatch(updateToken({ token: data.access_token, expires })) 20 | history.push('/dashboard') 21 | dispatch(refreshSaved()) 22 | localStorage.removeItem('saved') 23 | history.push('/loading') 24 | } 25 | }) 26 | .catch((err) => console.log(err)) 27 | 28 | return ( 29 | 40 | ) 41 | } 42 | 43 | export default RefreshButton 44 | -------------------------------------------------------------------------------- /src/containers/dashboard/sidebar/components/SideBarNavigation.jsx: -------------------------------------------------------------------------------- 1 | import { faList, faSortAlphaDown } from '@fortawesome/free-solid-svg-icons' 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import React from 'react' 4 | import { NavLink } from 'react-router-dom/cjs/react-router-dom.min' 5 | 6 | function SideBarNavigation() { 7 | return ( 8 | 43 | ) 44 | } 45 | 46 | export default SideBarNavigation 47 | -------------------------------------------------------------------------------- /src/containers/dashboard/sidebar/components/SignOutButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useHistory } from 'react-router-dom' 3 | import { useDispatch } from 'react-redux' 4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 5 | import { faSignOutAlt } from '@fortawesome/free-solid-svg-icons' 6 | import { clearState } from '../../../../redux/actions' 7 | 8 | function SignOutButton() { 9 | const dispatch = useDispatch() 10 | const history = useHistory() 11 | 12 | const signOut = () => { 13 | dispatch(clearState()) 14 | localStorage.clear() 15 | history.push('/') 16 | } 17 | 18 | return ( 19 | 30 | ) 31 | } 32 | 33 | export default SignOutButton 34 | -------------------------------------------------------------------------------- /src/containers/dashboard/sidebar/components/UserInfo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | 4 | function UserInfo() { 5 | const { avatar, karma, name } = useSelector((state) => state.user) 6 | return ( 7 |
8 | user avatar 13 |
14 |

15 | {name} 16 |

17 |
{karma} karma
18 |
19 |
20 | ) 21 | } 22 | 23 | export default UserInfo 24 | -------------------------------------------------------------------------------- /src/containers/homepage/Welcome.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Redirect } from 'react-router-dom' 3 | import { useSelector } from 'react-redux' 4 | import Login from './components/Login' 5 | import PreviewImage from '../../assets/images/preview.png' 6 | 7 | function Welcome() { 8 | const { 9 | user: { name, refresh_token: refreshToken, token }, 10 | saved: { links }, 11 | } = useSelector((state) => state) 12 | if (name && refreshToken && token && links.length) 13 | return 14 | return ( 15 |
16 |
17 |
18 |
19 |
Saveddit
20 |
Reddit Manager
21 |
22 |
23 |

Search, filter, sort, export, unsave.

24 |

Blazingly fast.

25 |
26 |
27 | 28 |
29 |
30 |
31 |
32 | dashboard of product 33 |
34 |
35 | ) 36 | } 37 | 38 | export default Welcome 39 | -------------------------------------------------------------------------------- /src/containers/homepage/components/Login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Axios from 'axios' 3 | import { v4 as uuidv4 } from 'uuid' 4 | 5 | const Login = () => { 6 | const handleAuthorize = () => { 7 | const seed = uuidv4() 8 | localStorage.clear() 9 | localStorage.setItem('seed', seed) 10 | Axios.post('/api/authorize', { 11 | seed, 12 | }) 13 | .then((res) => { 14 | window.location = res.data.url 15 | }) 16 | .catch((err) => console.log(err)) 17 | } 18 | return ( 19 |
20 | 27 |
28 | ) 29 | } 30 | 31 | export default Login 32 | -------------------------------------------------------------------------------- /src/containers/homepage/components/Reset.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function Reset() { 4 | const [hasReset, setHasReset] = React.useState(false) 5 | const reset = () => { 6 | localStorage.clear() 7 | setHasReset(true) 8 | } 9 | return ( 10 |

reset()} 12 | className={`font-bold ${ 13 | hasReset ? 'text-blue-500' : 'text-red-500' 14 | } outline-none self-center`} 15 | > 16 | {hasReset ? 'Try now!' : 'Not working?'} 17 |

18 | ) 19 | } 20 | 21 | export default Reset 22 | -------------------------------------------------------------------------------- /src/containers/loadingScreen/LoadingScreen.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import URLParse from 'url-parse' 3 | import { useDispatch, useSelector } from 'react-redux' 4 | import { Redirect } from 'react-router-dom' 5 | import { 6 | addBatch, 7 | setLoadingStatus, 8 | setTokens, 9 | setUserDetails, 10 | } from '../../redux/actions' 11 | 12 | const LoadingScreen = () => { 13 | const dispatch = useDispatch() 14 | const user = useSelector((state) => state.user) 15 | const { isLoading, total, afterListing, fetchCount } = useSelector( 16 | (state) => state.saved, 17 | ) 18 | 19 | useEffect(() => { 20 | const url = new URLParse(window.location, true) 21 | const seed = localStorage.getItem('seed') 22 | const fetchUserToken = (code) => { 23 | if (!user.token) { 24 | fetch(`/api/token?code=${code}`) 25 | .then((res) => res.json()) 26 | .then((data) => { 27 | const expires = Date.now() + 3600000 28 | dispatch( 29 | setTokens({ 30 | token: data.access_token, 31 | refresh_token: data.refresh_token, 32 | expires, 33 | }), 34 | ) 35 | fetchUserName(data.access_token) 36 | }) 37 | .catch((err) => console.log(err)) 38 | } 39 | } 40 | 41 | const fetchUserName = (token) => { 42 | fetch(`/api/username`, { 43 | method: 'POST', 44 | body: JSON.stringify({ 45 | token, 46 | }), 47 | }) 48 | .then((res) => res.json()) 49 | .then((data) => { 50 | dispatch( 51 | setUserDetails({ 52 | name: data.name, 53 | avatar: data.icon_img, 54 | account_created: data.created_utc, 55 | karma: data.total_karma, 56 | verified: data.verified, 57 | coins: data.coins, 58 | }), 59 | ) 60 | }) 61 | .catch((err) => console.log(err)) 62 | } 63 | 64 | if (url && url.query.state === seed) { 65 | fetchUserToken(url.query.code) 66 | } 67 | }, [user, dispatch]) 68 | 69 | useEffect(() => { 70 | if (user.token && user.name) { 71 | const fetchSaved = () => { 72 | fetch(`/api/fetch`, { 73 | method: 'POST', 74 | body: JSON.stringify({ 75 | token: user.token, 76 | username: user.name, 77 | }), 78 | }) 79 | .then((res) => res.json()) 80 | .then(({ after, dist, links }) => { 81 | dispatch(addBatch({ links, count: dist, afterListing: after })) 82 | }) 83 | .catch((err) => console.log(err)) 84 | } 85 | fetchSaved() 86 | } 87 | }, [user, dispatch]) 88 | 89 | useEffect(() => { 90 | const fetchSaved = async () => { 91 | fetch(`/api/fetch`, { 92 | method: 'POST', 93 | body: JSON.stringify({ 94 | token: user.token, 95 | username: user.name, 96 | afterListing, 97 | }), 98 | }) 99 | .then((res) => res.json()) 100 | .then(({ after, dist, links }) => { 101 | dispatch(addBatch({ links, count: dist, afterListing: after })) 102 | }) 103 | .catch((err) => console.log(err)) 104 | } 105 | if (afterListing && fetchCount === 100 && isLoading) { 106 | fetchSaved() 107 | } 108 | if (fetchCount < 100) { 109 | dispatch(setLoadingStatus({ status: false })) 110 | } 111 | }, [afterListing, fetchCount, user, dispatch, isLoading]) 112 | 113 | if (isLoading === false) { 114 | return 115 | } 116 | 117 | return ( 118 |
119 |
120 |

121 | Welcome, {user.name ? user.name : 'redditor.'}. 122 |

123 |

124 | Please wait while we fetch your saved threads and links, this may take 125 | a few seconds. 126 |

127 |

{isLoading ? `Count: ${total}` : 'Done!'}

128 |
129 |
130 | ) 131 | } 132 | 133 | export default LoadingScreen 134 | -------------------------------------------------------------------------------- /src/context/componentContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | const ComponentContext = createContext({}) 4 | 5 | export { ComponentContext } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './assets/main.css' 4 | import './index.scss' 5 | import { Provider } from 'react-redux' 6 | import App from './App' 7 | import * as serviceWorker from './serviceWorker' 8 | 9 | import store from './redux/store' 10 | 11 | ReactDOM.render( 12 | 13 | 14 | 15 | 16 | , 17 | document.getElementById('root'), 18 | ) 19 | 20 | // If you want your app to work offline and load faster, you can change 21 | // unregister() to register() below. Note this comes with some pitfalls. 22 | // Learn more about service workers: https://bit.ly/CRA-PWA 23 | serviceWorker.unregister() 24 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | 15 | .logo { 16 | font-size: 6rem; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | .subtitle { 22 | top: -20px; 23 | } 24 | 25 | .welcome-details { 26 | height: 30vh; 27 | } 28 | 29 | .reddit-font { 30 | font-family: 'Quicksand', sans-serif; 31 | } 32 | 33 | .sidebar-wrapper { 34 | border-right: 1px solid rgb(244, 244, 244); 35 | .sidebar { 36 | .navigation { 37 | a { 38 | color: #2b6cb0; 39 | &.active { 40 | color: white; 41 | } 42 | .route-name { 43 | display: block; 44 | } 45 | .icon { 46 | display: none; 47 | } 48 | } 49 | } 50 | .actions { 51 | .refresh { 52 | .icon { 53 | display: none; 54 | } 55 | } 56 | .sign-out { 57 | .icon { 58 | display: none; 59 | } 60 | } 61 | } 62 | &.closed { 63 | .user-info { 64 | .avatar { 65 | } 66 | .details { 67 | display: none; 68 | } 69 | } 70 | .navigation { 71 | a { 72 | .route-name { 73 | display: none; 74 | } 75 | .icon { 76 | display: block; 77 | } 78 | } 79 | } 80 | .actions { 81 | .refresh { 82 | .title { 83 | display: none; 84 | } 85 | .icon { 86 | display: block; 87 | } 88 | } 89 | .sign-out { 90 | .title { 91 | display: none; 92 | } 93 | .icon { 94 | display: block; 95 | } 96 | } 97 | .export { 98 | select { 99 | color: transparent; 100 | } 101 | .icon { 102 | left: 50%; 103 | transform: translate(-50%, 0); 104 | } 105 | } 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/redux/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const ADD_LINKS = 'ADD_LINKS' 2 | export const ADD_BATCH = 'ADD_BATCH' 3 | export const SET_LOADING_STATUS = 'SET_LOADING_STATUS' 4 | export const SET_AFTER = 'SET_AFTER' 5 | export const SET_FETCH_COUNT = 'SET_FETCH_COUNT' 6 | export const LOAD_NUMBERED_PAGE = 'LOAD_NUMBERED_PAGE' 7 | export const REFRESH = 'REFRESH' 8 | export const SET_SEARCH_RESULTS = 'SET_SEARCH_RESULTS' 9 | export const SET_SUBREDDIT_FILTER = 'SET_SUBREDDIT_FILTER' 10 | export const SET_SORTING_METHOD = 'SET_SORTING_METHOD' 11 | export const UNSAVE_POST = 'UNSAVE_POST' 12 | export const RESET_PAGE_NUMBER = 'RESET_PAGENUMBER' 13 | export const CLEAR_STATE = 'CLEAR_STATE' 14 | export const SET_NSFW_FILTER = 'SET_NSFW_FILTER' 15 | export const RESET_NSFW_FILTER = 'RESET_NSFW_FILTER' 16 | 17 | // User Action Types 18 | export const SET_TOKENS = 'SET_TOKENS' 19 | export const SET_USER_DETAILS = 'SET_USER_DETAILS' 20 | export const UPDATE_TOKEN = 'UPDATE_TOKEN' 21 | -------------------------------------------------------------------------------- /src/redux/actions.js: -------------------------------------------------------------------------------- 1 | import * as Types from './actionTypes' 2 | 3 | export const addLinks = ({ links }) => ({ 4 | type: Types.ADD_LINKS, 5 | links, 6 | }) 7 | 8 | export const addBatch = ({ links, afterListing, count }) => ({ 9 | type: Types.ADD_BATCH, 10 | links, 11 | afterListing, 12 | count, 13 | }) 14 | 15 | export const setLoadingStatus = ({ status }) => ({ 16 | type: Types.SET_LOADING_STATUS, 17 | status, 18 | }) 19 | 20 | export const setAfter = ({ afterListing }) => ({ 21 | type: Types.SET_AFTER, 22 | afterListing, 23 | }) 24 | 25 | export const setFetchCount = ({ count }) => ({ 26 | type: Types.SET_FETCH_COUNT, 27 | count, 28 | }) 29 | 30 | // load_numbered_page - for initial load (1) and any (n) page thereafter. 31 | export const loadNumberedPage = ({ page }) => ({ 32 | type: Types.LOAD_NUMBERED_PAGE, 33 | page, 34 | }) 35 | 36 | export const refreshSaved = () => ({ 37 | type: Types.REFRESH, 38 | }) 39 | 40 | export const setSearchResults = ({ value }) => ({ 41 | type: Types.SET_SEARCH_RESULTS, 42 | value, 43 | }) 44 | 45 | export const setSubredditFilter = ({ subreddit }) => ({ 46 | type: Types.SET_SUBREDDIT_FILTER, 47 | subreddit, 48 | }) 49 | 50 | export const setSortingMethod = ({ method }) => ({ 51 | type: Types.SET_SORTING_METHOD, 52 | method, 53 | }) 54 | 55 | export const unsavePost = ({ id }) => ({ 56 | type: Types.UNSAVE_POST, 57 | id, 58 | }) 59 | 60 | export const resetPageNumber = () => ({ 61 | type: Types.RESET_PAGE_NUMBER, 62 | }) 63 | 64 | export const clearState = () => ({ 65 | type: Types.CLEAR_STATE, 66 | }) 67 | 68 | export const setNsfwFilter = () => ({ 69 | type: Types.SET_NSFW_FILTER, 70 | }) 71 | 72 | export const resetNsfwFilter = () => ({ 73 | type: Types.RESET_NSFW_FILTER, 74 | }) 75 | 76 | export const setTokens = ({ token, refresh_token, expires }) => ({ 77 | type: Types.SET_TOKENS, 78 | token, 79 | refresh_token, 80 | expires, 81 | }) 82 | 83 | export const setUserDetails = ({ 84 | name, 85 | avatar, 86 | account_created, 87 | karma, 88 | verified, 89 | coins, 90 | }) => ({ 91 | type: Types.SET_USER_DETAILS, 92 | name, 93 | avatar, 94 | account_created, 95 | karma, 96 | verified, 97 | coins, 98 | }) 99 | 100 | export const updateToken = ({ token, expires }) => ({ 101 | type: Types.UPDATE_TOKEN, 102 | token, 103 | expires, 104 | }) 105 | -------------------------------------------------------------------------------- /src/redux/localStorage.js: -------------------------------------------------------------------------------- 1 | export const loadState = () => { 2 | try { 3 | const serializedState = localStorage.getItem('data') 4 | if (serializedState === null) { 5 | return undefined 6 | } 7 | return JSON.parse(serializedState) 8 | } catch (error) { 9 | console.log('localStorage.js error: ', error) 10 | return undefined 11 | } 12 | } 13 | 14 | export const saveState = (state) => { 15 | try { 16 | const serializedState = JSON.stringify(state) 17 | localStorage.setItem('data', serializedState) 18 | } catch (error) { 19 | console.log('localStorage.js error: ', error) 20 | return undefined 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import saved from './saved' 3 | import user from './user' 4 | 5 | export default combineReducers({ 6 | saved, 7 | user, 8 | }) 9 | -------------------------------------------------------------------------------- /src/redux/reducers/saved.js: -------------------------------------------------------------------------------- 1 | import * as Types from '../actionTypes' 2 | 3 | const initialState = { 4 | links: [], 5 | isLoading: true, 6 | hasErrored: false, 7 | afterListing: '', 8 | fetchCount: 100, 9 | total: 0, 10 | subredditFilter: null, 11 | nsfwFilter: false, 12 | totalPages: 0, 13 | searchResults: [], 14 | searchPages: 0, 15 | searchTotal: 0, 16 | } 17 | 18 | export default function saved(state = initialState, action) { 19 | switch (action.type) { 20 | case Types.ADD_LINKS: 21 | return { 22 | ...state, 23 | links: [...state.links, ...action.links], 24 | total: state.links.length + action.links.length, 25 | } 26 | case Types.ADD_BATCH: 27 | return { 28 | ...state, 29 | links: [...state.links, ...action.links], 30 | total: state.links.length + action.links.length, 31 | totalPages: Math.ceil((state.links.length + action.links.length) / 20), 32 | afterListing: action.afterListing, 33 | fetchCount: action.count, 34 | currentPage: 1, 35 | filterValues: [], 36 | pageResults: [], 37 | sortResults: [], 38 | searchResults: [...state.links, ...action.links], 39 | searchPages: Math.ceil((state.links.length + action.links.length) / 20), 40 | } 41 | case Types.SET_LOADING_STATUS: 42 | return { 43 | ...state, 44 | isLoading: action.status, 45 | } 46 | case Types.SET_AFTER: 47 | return { 48 | ...state, 49 | afterListing: action.afterListing, 50 | } 51 | case Types.SET_FETCH_COUNT: 52 | return { 53 | ...state, 54 | fetchCount: action.count, 55 | } 56 | case Types.LOAD_NUMBERED_PAGE: { 57 | const lowerCount = (action.page - 1) * 20 58 | const upperCount = lowerCount + 20 59 | const pageResults = state.searchResults.slice(lowerCount, upperCount) 60 | return { 61 | ...state, 62 | pageResults, 63 | currentPage: action.page, 64 | } 65 | } 66 | case Types.REFRESH: 67 | return { ...initialState } 68 | case Types.SET_SUBREDDIT_FILTER: 69 | return { 70 | ...state, 71 | subredditFilter: action.subreddit, 72 | } 73 | case Types.SET_NSFW_FILTER: 74 | return { 75 | ...state, 76 | nsfwFilter: true, 77 | } 78 | case Types.RESET_NSFW_FILTER: 79 | return { 80 | ...state, 81 | nsfwFilter: false, 82 | } 83 | case Types.SET_SORTING_METHOD: 84 | switch (action.method) { 85 | case 'a-z': { 86 | const sorted = state.searchResults.sort((a, b) => 87 | a.title.localeCompare(b.title), 88 | ) 89 | return { 90 | ...state, 91 | searchResults: sorted, 92 | pageResults: sorted.slice(0, 20), 93 | } 94 | } 95 | case 'z-a': { 96 | const sorted = state.searchResults.sort((a, b) => 97 | b.title.localeCompare(a.title), 98 | ) 99 | return { 100 | ...state, 101 | searchResults: sorted, 102 | pageResults: sorted.slice(0, 20), 103 | } 104 | } 105 | case 'dateNew': { 106 | const sorted = state.searchResults.sort((a, b) => 107 | a.createdUtc > b.createdUtc ? -1 : 1, 108 | ) 109 | return { 110 | ...state, 111 | searchResults: sorted, 112 | pageResults: sorted.slice(0, 20), 113 | } 114 | } 115 | case 'dateOld': { 116 | const sorted = state.searchResults.sort((a, b) => 117 | a.createdUtc > b.createdUtc ? 1 : -1, 118 | ) 119 | return { 120 | ...state, 121 | searchResults: sorted, 122 | pageResults: sorted.slice(0, 20), 123 | } 124 | } 125 | case 'popularity': { 126 | const sorted = state.searchResults.sort((a, b) => 127 | a.score > b.score ? -1 : 1, 128 | ) 129 | return { 130 | ...state, 131 | searchResults: sorted, 132 | pageResults: sorted.slice(0, 20), 133 | } 134 | } 135 | case 'lastSaved': { 136 | let searchResults = [...state.links] 137 | if (state.subredditFilter) { 138 | searchResults = searchResults.filter( 139 | (post) => post.subreddit === state.subredditFilter, 140 | ) 141 | } 142 | return { 143 | ...state, 144 | searchResults, 145 | pageResults: searchResults.slice(0, 20), 146 | searchPages: Math.ceil(searchResults.length / 20), 147 | } 148 | } 149 | default: 150 | return {} 151 | } 152 | case Types.SET_SEARCH_RESULTS: { 153 | let copy = [...state.links] 154 | if (state.subredditFilter) { 155 | copy = copy.filter((post) => post.subreddit === state.subredditFilter) 156 | } else if (state.nsfwFilter === true) { 157 | copy = copy.filter((post) => post.over18 === true) 158 | } 159 | const searchResults = copy.filter((link) => 160 | link.title.toLowerCase().includes(action.value.toLowerCase()), 161 | ) 162 | return { 163 | ...state, 164 | searchResults, 165 | pageResults: searchResults.slice(0, 20), 166 | searchPages: Math.ceil(searchResults.length / 20), 167 | searchTotal: searchResults.length, 168 | currentPage: 1, 169 | } 170 | } 171 | case Types.UNSAVE_POST: 172 | return { 173 | ...state, 174 | links: state.links.filter((link) => link.id !== action.id), 175 | searchResults: state.searchResults.filter( 176 | (link) => link.id !== action.id, 177 | ), 178 | pageResults: state.pageResults.filter((link) => link.id !== action.id), 179 | } 180 | case Types.RESET_PAGE_NUMBER: 181 | return { 182 | ...state, 183 | currentPage: 1, 184 | } 185 | case Types.CLEAR_STATE: 186 | return {} 187 | default: 188 | return state 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/redux/reducers/user.js: -------------------------------------------------------------------------------- 1 | import * as Types from '../actionTypes' 2 | 3 | const initialState = { 4 | account_created: null, 5 | avatar: null, 6 | coins: null, 7 | karma: null, 8 | name: null, 9 | refresh_token: null, 10 | token: null, 11 | verified: null, 12 | } 13 | 14 | export default function (state = initialState, action) { 15 | switch (action.type) { 16 | case Types.SET_TOKENS: 17 | return { 18 | ...state, 19 | token: action.token, 20 | refresh_token: action.refresh_token, 21 | expires: action.expires, 22 | } 23 | case Types.SET_USER_DETAILS: 24 | return { 25 | ...state, 26 | name: action.name, 27 | avatar: action.avatar, 28 | account_created: action.account_created, 29 | karma: action.karma, 30 | verified: action.verified, 31 | coins: action.coins, 32 | } 33 | 34 | case Types.UPDATE_TOKEN: 35 | return { 36 | ...state, 37 | token: action.token, 38 | expires: action.expires, 39 | } 40 | 41 | case Types.CLEAR_STATE: 42 | return {} 43 | default: 44 | return state 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'redux' 2 | import throttle from 'lodash.throttle' 3 | import { loadState, saveState } from './localStorage' 4 | import rootReducer from './reducers' 5 | 6 | const persistedState = loadState() 7 | const store = createStore( 8 | rootReducer, 9 | persistedState, 10 | window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), 11 | ) 12 | 13 | store.subscribe( 14 | throttle(() => { 15 | saveState({ 16 | saved: store.getState().saved, 17 | user: store.getState().user, 18 | }) 19 | }, 1000), 20 | ) 21 | 22 | export default store 23 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, 20 | ), 21 | ) 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config) 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA', 47 | ) 48 | }) 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config) 52 | } 53 | }) 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then((registration) => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing 63 | if (installingWorker == null) { 64 | return 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.', 75 | ) 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration) 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.') 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration) 90 | } 91 | } 92 | } 93 | } 94 | } 95 | }) 96 | .catch((error) => { 97 | console.error('Error during service worker registration:', error) 98 | }) 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then((response) => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type') 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then((registration) => { 115 | registration.unregister().then(() => { 116 | window.location.reload() 117 | }) 118 | }) 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config) 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.', 127 | ) 128 | }) 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then((registration) => { 135 | registration.unregister() 136 | }) 137 | .catch((error) => { 138 | console.error(error.message) 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect' 6 | -------------------------------------------------------------------------------- /tailwind.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | target: 'relaxed', 4 | prefix: '', 5 | important: false, 6 | separator: ':', 7 | theme: { 8 | screens: { 9 | sm: '640px', 10 | md: '768px', 11 | lg: '1024px', 12 | xl: '1280px', 13 | }, 14 | colors: { 15 | transparent: 'transparent', 16 | current: 'currentColor', 17 | 18 | black: '#000', 19 | white: '#fff', 20 | 21 | gray: { 22 | 100: '#f7fafc', 23 | 200: '#edf2f7', 24 | 300: '#e2e8f0', 25 | 400: '#cbd5e0', 26 | 500: '#a0aec0', 27 | 600: '#718096', 28 | 700: '#4a5568', 29 | 800: '#2d3748', 30 | 900: '#1a202c', 31 | }, 32 | red: { 33 | 100: '#fff5f5', 34 | 200: '#fed7d7', 35 | 300: '#feb2b2', 36 | 400: '#fc8181', 37 | 500: '#f56565', 38 | 600: '#e53e3e', 39 | 700: '#c53030', 40 | 800: '#9b2c2c', 41 | 900: '#742a2a', 42 | }, 43 | orange: { 44 | 100: '#fffaf0', 45 | 200: '#feebc8', 46 | 300: '#fbd38d', 47 | 400: '#f6ad55', 48 | 500: '#ed8936', 49 | 600: '#dd6b20', 50 | 700: '#c05621', 51 | 800: '#9c4221', 52 | 900: '#7b341e', 53 | }, 54 | yellow: { 55 | 100: '#fffff0', 56 | 200: '#fefcbf', 57 | 300: '#faf089', 58 | 400: '#f6e05e', 59 | 500: '#ecc94b', 60 | 600: '#d69e2e', 61 | 700: '#b7791f', 62 | 800: '#975a16', 63 | 900: '#744210', 64 | }, 65 | green: { 66 | 100: '#f0fff4', 67 | 200: '#c6f6d5', 68 | 300: '#9ae6b4', 69 | 400: '#68d391', 70 | 500: '#48bb78', 71 | 600: '#38a169', 72 | 700: '#2f855a', 73 | 800: '#276749', 74 | 900: '#22543d', 75 | }, 76 | teal: { 77 | 100: '#e6fffa', 78 | 200: '#b2f5ea', 79 | 300: '#81e6d9', 80 | 400: '#4fd1c5', 81 | 500: '#38b2ac', 82 | 600: '#319795', 83 | 700: '#2c7a7b', 84 | 800: '#285e61', 85 | 900: '#234e52', 86 | }, 87 | blue: { 88 | 100: '#ebf8ff', 89 | 200: '#bee3f8', 90 | 300: '#90cdf4', 91 | 400: '#63b3ed', 92 | 500: '#4299e1', 93 | 600: '#3182ce', 94 | 700: '#2b6cb0', 95 | 800: '#2c5282', 96 | 900: '#2a4365', 97 | }, 98 | indigo: { 99 | 100: '#ebf4ff', 100 | 200: '#c3dafe', 101 | 300: '#a3bffa', 102 | 400: '#7f9cf5', 103 | 500: '#667eea', 104 | 600: '#5a67d8', 105 | 700: '#4c51bf', 106 | 800: '#434190', 107 | 900: '#3c366b', 108 | }, 109 | purple: { 110 | 100: '#faf5ff', 111 | 200: '#e9d8fd', 112 | 300: '#d6bcfa', 113 | 400: '#b794f4', 114 | 500: '#9f7aea', 115 | 600: '#805ad5', 116 | 700: '#6b46c1', 117 | 800: '#553c9a', 118 | 900: '#44337a', 119 | }, 120 | pink: { 121 | 100: '#fff5f7', 122 | 200: '#fed7e2', 123 | 300: '#fbb6ce', 124 | 400: '#f687b3', 125 | 500: '#ed64a6', 126 | 600: '#d53f8c', 127 | 700: '#b83280', 128 | 800: '#97266d', 129 | 900: '#702459', 130 | }, 131 | }, 132 | spacing: { 133 | px: '1px', 134 | 0: '0', 135 | 1: '0.25rem', 136 | 2: '0.5rem', 137 | 3: '0.75rem', 138 | 4: '1rem', 139 | 5: '1.25rem', 140 | 6: '1.5rem', 141 | 8: '2rem', 142 | 10: '2.5rem', 143 | 12: '3rem', 144 | 16: '4rem', 145 | 20: '5rem', 146 | 24: '6rem', 147 | 32: '8rem', 148 | 40: '10rem', 149 | 48: '12rem', 150 | 56: '14rem', 151 | 64: '16rem', 152 | }, 153 | backgroundColor: (theme) => theme('colors'), 154 | backgroundImage: { 155 | none: 'none', 156 | 'gradient-to-t': 'linear-gradient(to top, var(--gradient-color-stops))', 157 | 'gradient-to-tr': 158 | 'linear-gradient(to top right, var(--gradient-color-stops))', 159 | 'gradient-to-r': 'linear-gradient(to right, var(--gradient-color-stops))', 160 | 'gradient-to-br': 161 | 'linear-gradient(to bottom right, var(--gradient-color-stops))', 162 | 'gradient-to-b': 163 | 'linear-gradient(to bottom, var(--gradient-color-stops))', 164 | 'gradient-to-bl': 165 | 'linear-gradient(to bottom left, var(--gradient-color-stops))', 166 | 'gradient-to-l': 'linear-gradient(to left, var(--gradient-color-stops))', 167 | 'gradient-to-tl': 168 | 'linear-gradient(to top left, var(--gradient-color-stops))', 169 | }, 170 | gradientColorStops: (theme) => theme('colors'), 171 | backgroundOpacity: (theme) => theme('opacity'), 172 | backgroundPosition: { 173 | bottom: 'bottom', 174 | center: 'center', 175 | left: 'left', 176 | 'left-bottom': 'left bottom', 177 | 'left-top': 'left top', 178 | right: 'right', 179 | 'right-bottom': 'right bottom', 180 | 'right-top': 'right top', 181 | top: 'top', 182 | }, 183 | backgroundSize: { 184 | auto: 'auto', 185 | cover: 'cover', 186 | contain: 'contain', 187 | }, 188 | borderColor: (theme) => ({ 189 | ...theme('colors'), 190 | default: theme('colors.gray.300', 'currentColor'), 191 | }), 192 | borderOpacity: (theme) => theme('opacity'), 193 | borderRadius: { 194 | none: '0', 195 | sm: '0.125rem', 196 | default: '0.25rem', 197 | md: '0.375rem', 198 | lg: '0.5rem', 199 | full: '9999px', 200 | }, 201 | borderWidth: { 202 | default: '1px', 203 | 0: '0', 204 | 2: '2px', 205 | 4: '4px', 206 | 8: '8px', 207 | }, 208 | boxShadow: { 209 | xs: '0 0 0 1px rgba(0, 0, 0, 0.05)', 210 | sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)', 211 | default: 212 | '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', 213 | md: 214 | '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)', 215 | lg: 216 | '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', 217 | xl: 218 | '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', 219 | '2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)', 220 | inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.06)', 221 | outline: '0 0 0 3px rgba(66, 153, 225, 0.5)', 222 | none: 'none', 223 | }, 224 | container: {}, 225 | cursor: { 226 | auto: 'auto', 227 | default: 'default', 228 | pointer: 'pointer', 229 | wait: 'wait', 230 | text: 'text', 231 | move: 'move', 232 | 'not-allowed': 'not-allowed', 233 | }, 234 | divideColor: (theme) => theme('borderColor'), 235 | divideOpacity: (theme) => theme('borderOpacity'), 236 | divideWidth: (theme) => theme('borderWidth'), 237 | fill: { 238 | current: 'currentColor', 239 | }, 240 | flex: { 241 | 1: '1 1 0%', 242 | auto: '1 1 auto', 243 | initial: '0 1 auto', 244 | none: 'none', 245 | }, 246 | flexGrow: { 247 | 0: '0', 248 | default: '1', 249 | }, 250 | flexShrink: { 251 | 0: '0', 252 | default: '1', 253 | }, 254 | fontFamily: { 255 | sans: [ 256 | 'system-ui', 257 | '-apple-system', 258 | 'BlinkMacSystemFont', 259 | '"Segoe UI"', 260 | 'Roboto', 261 | '"Helvetica Neue"', 262 | 'Arial', 263 | '"Noto Sans"', 264 | 'sans-serif', 265 | '"Apple Color Emoji"', 266 | '"Segoe UI Emoji"', 267 | '"Segoe UI Symbol"', 268 | '"Noto Color Emoji"', 269 | ], 270 | serif: ['Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'], 271 | mono: [ 272 | 'Menlo', 273 | 'Monaco', 274 | 'Consolas', 275 | '"Liberation Mono"', 276 | '"Courier New"', 277 | 'monospace', 278 | ], 279 | }, 280 | fontSize: { 281 | xs: '0.75rem', 282 | sm: '0.875rem', 283 | base: '1rem', 284 | lg: '1.125rem', 285 | xl: '1.25rem', 286 | '2xl': '1.5rem', 287 | '3xl': '1.875rem', 288 | '4xl': '2.25rem', 289 | '5xl': '3rem', 290 | '6xl': '4rem', 291 | }, 292 | fontWeight: { 293 | hairline: '100', 294 | thin: '200', 295 | light: '300', 296 | normal: '400', 297 | medium: '500', 298 | semibold: '600', 299 | bold: '700', 300 | extrabold: '800', 301 | black: '900', 302 | }, 303 | height: (theme) => ({ 304 | auto: 'auto', 305 | ...theme('spacing'), 306 | full: '100%', 307 | screen: '100vh', 308 | half: '50%', 309 | '1/2': '50%', 310 | }), 311 | inset: { 312 | 0: '0', 313 | auto: 'auto', 314 | }, 315 | letterSpacing: { 316 | tighter: '-0.05em', 317 | tight: '-0.025em', 318 | normal: '0', 319 | wide: '0.025em', 320 | wider: '0.05em', 321 | widest: '0.1em', 322 | }, 323 | lineHeight: { 324 | none: '1', 325 | tight: '1.25', 326 | snug: '1.375', 327 | normal: '1.5', 328 | relaxed: '1.625', 329 | loose: '2', 330 | 3: '.75rem', 331 | 4: '1rem', 332 | 5: '1.25rem', 333 | 6: '1.5rem', 334 | 7: '1.75rem', 335 | 8: '2rem', 336 | 9: '2.25rem', 337 | 10: '2.5rem', 338 | }, 339 | listStyleType: { 340 | none: 'none', 341 | disc: 'disc', 342 | decimal: 'decimal', 343 | }, 344 | margin: (theme, { negative }) => ({ 345 | auto: 'auto', 346 | ...theme('spacing'), 347 | ...negative(theme('spacing')), 348 | }), 349 | maxHeight: { 350 | full: '100%', 351 | screen: '100vh', 352 | }, 353 | maxWidth: (theme, { breakpoints }) => ({ 354 | none: 'none', 355 | xs: '20rem', 356 | sm: '24rem', 357 | md: '28rem', 358 | lg: '32rem', 359 | xl: '36rem', 360 | '2xl': '42rem', 361 | '3xl': '48rem', 362 | '4xl': '56rem', 363 | '5xl': '64rem', 364 | '6xl': '72rem', 365 | full: '100%', 366 | 'full-sidebar': 'calc(100% - 200px)', 367 | ...breakpoints(theme('screens')), 368 | }), 369 | minHeight: { 370 | 0: '0', 371 | full: '100%', 372 | screen: '100vh', 373 | }, 374 | minWidth: { 375 | 0: '0', 376 | '2/6': '33%', 377 | '1/4': '25%', 378 | 200: '200px', 379 | half: '50%', 380 | full: '100%', 381 | }, 382 | objectPosition: { 383 | bottom: 'bottom', 384 | center: 'center', 385 | left: 'left', 386 | 'left-bottom': 'left bottom', 387 | 'left-top': 'left top', 388 | right: 'right', 389 | 'right-bottom': 'right bottom', 390 | 'right-top': 'right top', 391 | top: 'top', 392 | }, 393 | opacity: { 394 | 0: '0', 395 | 25: '0.25', 396 | 50: '0.5', 397 | 75: '0.75', 398 | 100: '1', 399 | }, 400 | order: { 401 | first: '-9999', 402 | last: '9999', 403 | none: '0', 404 | 1: '1', 405 | 2: '2', 406 | 3: '3', 407 | 4: '4', 408 | 5: '5', 409 | 6: '6', 410 | 7: '7', 411 | 8: '8', 412 | 9: '9', 413 | 10: '10', 414 | 11: '11', 415 | 12: '12', 416 | }, 417 | padding: (theme) => theme('spacing'), 418 | placeholderColor: (theme) => theme('colors'), 419 | placeholderOpacity: (theme) => theme('opacity'), 420 | space: (theme, { negative }) => ({ 421 | ...theme('spacing'), 422 | ...negative(theme('spacing')), 423 | }), 424 | stroke: { 425 | current: 'currentColor', 426 | }, 427 | strokeWidth: { 428 | 0: '0', 429 | 1: '1', 430 | 2: '2', 431 | }, 432 | textColor: (theme) => theme('colors'), 433 | textOpacity: (theme) => theme('opacity'), 434 | width: (theme) => ({ 435 | auto: 'auto', 436 | ...theme('spacing'), 437 | '1/2': '50%', 438 | '1/3': '33.333333%', 439 | '2/3': '66.666667%', 440 | '1/4': '25%', 441 | '2/4': '50%', 442 | '3/4': '75%', 443 | '1/5': '20%', 444 | '2/5': '40%', 445 | '3/5': '60%', 446 | '4/5': '80%', 447 | '1/6': '16.666667%', 448 | '2/6': '33.333333%', 449 | '3/6': '50%', 450 | '4/6': '66.666667%', 451 | '5/6': '83.333333%', 452 | '1/12': '8.333333%', 453 | '2/12': '16.666667%', 454 | '3/12': '25%', 455 | '4/12': '33.333333%', 456 | '5/12': '41.666667%', 457 | '6/12': '50%', 458 | '7/12': '58.333333%', 459 | '8/12': '66.666667%', 460 | '9/12': '75%', 461 | '10/12': '83.333333%', 462 | '11/12': '91.666667%', 463 | full: '100%', 464 | screen: '100vw', 465 | }), 466 | zIndex: { 467 | auto: 'auto', 468 | 0: '0', 469 | 10: '10', 470 | 20: '20', 471 | 30: '30', 472 | 40: '40', 473 | 50: '50', 474 | }, 475 | gap: (theme) => theme('spacing'), 476 | gridTemplateColumns: { 477 | none: 'none', 478 | 1: 'repeat(1, minmax(0, 1fr))', 479 | 2: 'repeat(2, minmax(0, 1fr))', 480 | 3: 'repeat(3, minmax(0, 1fr))', 481 | 4: 'repeat(4, minmax(0, 1fr))', 482 | 5: 'repeat(5, minmax(0, 1fr))', 483 | 6: 'repeat(6, minmax(0, 1fr))', 484 | 7: 'repeat(7, minmax(0, 1fr))', 485 | 8: 'repeat(8, minmax(0, 1fr))', 486 | 9: 'repeat(9, minmax(0, 1fr))', 487 | 10: 'repeat(10, minmax(0, 1fr))', 488 | 11: 'repeat(11, minmax(0, 1fr))', 489 | 12: 'repeat(12, minmax(0, 1fr))', 490 | }, 491 | gridColumn: { 492 | auto: 'auto', 493 | 'span-1': 'span 1 / span 1', 494 | 'span-2': 'span 2 / span 2', 495 | 'span-3': 'span 3 / span 3', 496 | 'span-4': 'span 4 / span 4', 497 | 'span-5': 'span 5 / span 5', 498 | 'span-6': 'span 6 / span 6', 499 | 'span-7': 'span 7 / span 7', 500 | 'span-8': 'span 8 / span 8', 501 | 'span-9': 'span 9 / span 9', 502 | 'span-10': 'span 10 / span 10', 503 | 'span-11': 'span 11 / span 11', 504 | 'span-12': 'span 12 / span 12', 505 | }, 506 | gridColumnStart: { 507 | auto: 'auto', 508 | 1: '1', 509 | 2: '2', 510 | 3: '3', 511 | 4: '4', 512 | 5: '5', 513 | 6: '6', 514 | 7: '7', 515 | 8: '8', 516 | 9: '9', 517 | 10: '10', 518 | 11: '11', 519 | 12: '12', 520 | 13: '13', 521 | }, 522 | gridColumnEnd: { 523 | auto: 'auto', 524 | 1: '1', 525 | 2: '2', 526 | 3: '3', 527 | 4: '4', 528 | 5: '5', 529 | 6: '6', 530 | 7: '7', 531 | 8: '8', 532 | 9: '9', 533 | 10: '10', 534 | 11: '11', 535 | 12: '12', 536 | 13: '13', 537 | }, 538 | gridTemplateRows: { 539 | none: 'none', 540 | 1: 'repeat(1, minmax(0, 1fr))', 541 | 2: 'repeat(2, minmax(0, 1fr))', 542 | 3: 'repeat(3, minmax(0, 1fr))', 543 | 4: 'repeat(4, minmax(0, 1fr))', 544 | 5: 'repeat(5, minmax(0, 1fr))', 545 | 6: 'repeat(6, minmax(0, 1fr))', 546 | }, 547 | gridRow: { 548 | auto: 'auto', 549 | 'span-1': 'span 1 / span 1', 550 | 'span-2': 'span 2 / span 2', 551 | 'span-3': 'span 3 / span 3', 552 | 'span-4': 'span 4 / span 4', 553 | 'span-5': 'span 5 / span 5', 554 | 'span-6': 'span 6 / span 6', 555 | }, 556 | gridRowStart: { 557 | auto: 'auto', 558 | 1: '1', 559 | 2: '2', 560 | 3: '3', 561 | 4: '4', 562 | 5: '5', 563 | 6: '6', 564 | 7: '7', 565 | }, 566 | gridRowEnd: { 567 | auto: 'auto', 568 | 1: '1', 569 | 2: '2', 570 | 3: '3', 571 | 4: '4', 572 | 5: '5', 573 | 6: '6', 574 | 7: '7', 575 | }, 576 | transformOrigin: { 577 | center: 'center', 578 | top: 'top', 579 | 'top-right': 'top right', 580 | right: 'right', 581 | 'bottom-right': 'bottom right', 582 | bottom: 'bottom', 583 | 'bottom-left': 'bottom left', 584 | left: 'left', 585 | 'top-left': 'top left', 586 | }, 587 | scale: { 588 | 0: '0', 589 | 50: '.5', 590 | 75: '.75', 591 | 90: '.9', 592 | 95: '.95', 593 | 100: '1', 594 | 105: '1.05', 595 | 110: '1.1', 596 | 125: '1.25', 597 | 150: '1.5', 598 | }, 599 | rotate: { 600 | '-180': '-180deg', 601 | '-90': '-90deg', 602 | '-45': '-45deg', 603 | 0: '0', 604 | 45: '45deg', 605 | 90: '90deg', 606 | 180: '180deg', 607 | }, 608 | translate: (theme, { negative }) => ({ 609 | ...theme('spacing'), 610 | ...negative(theme('spacing')), 611 | '-full': '-100%', 612 | '-1/2': '-50%', 613 | '1/2': '50%', 614 | full: '100%', 615 | }), 616 | skew: { 617 | '-12': '-12deg', 618 | '-6': '-6deg', 619 | '-3': '-3deg', 620 | 0: '0', 621 | 3: '3deg', 622 | 6: '6deg', 623 | 12: '12deg', 624 | }, 625 | transitionProperty: { 626 | none: 'none', 627 | all: 'all', 628 | default: 629 | 'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform', 630 | colors: 'background-color, border-color, color, fill, stroke', 631 | opacity: 'opacity', 632 | shadow: 'box-shadow', 633 | transform: 'transform', 634 | }, 635 | transitionTimingFunction: { 636 | linear: 'linear', 637 | in: 'cubic-bezier(0.4, 0, 1, 1)', 638 | out: 'cubic-bezier(0, 0, 0.2, 1)', 639 | 'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)', 640 | }, 641 | transitionDuration: { 642 | 75: '75ms', 643 | 100: '100ms', 644 | 150: '150ms', 645 | 200: '200ms', 646 | 300: '300ms', 647 | 500: '500ms', 648 | 700: '700ms', 649 | 1000: '1000ms', 650 | }, 651 | transitionDelay: { 652 | 75: '75ms', 653 | 100: '100ms', 654 | 150: '150ms', 655 | 200: '200ms', 656 | 300: '300ms', 657 | 500: '500ms', 658 | 700: '700ms', 659 | 1000: '1000ms', 660 | }, 661 | animation: { 662 | none: 'none', 663 | spin: 'spin 1s linear infinite', 664 | ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite', 665 | pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 666 | bounce: 'bounce 1s infinite', 667 | }, 668 | keyframes: { 669 | spin: { 670 | to: { transform: 'rotate(360deg)' }, 671 | }, 672 | ping: { 673 | '75%, 100%': { transform: 'scale(2)', opacity: '0' }, 674 | }, 675 | pulse: { 676 | '50%': { opacity: '.5' }, 677 | }, 678 | bounce: { 679 | '0%, 100%': { 680 | transform: 'translateY(-25%)', 681 | animationTimingFunction: 'cubic-bezier(0.8,0,1,1)', 682 | }, 683 | '50%': { 684 | transform: 'none', 685 | animationTimingFunction: 'cubic-bezier(0,0,0.2,1)', 686 | }, 687 | }, 688 | }, 689 | }, 690 | variants: { 691 | accessibility: ['responsive', 'focus'], 692 | alignContent: ['responsive'], 693 | alignItems: ['responsive'], 694 | alignSelf: ['responsive'], 695 | appearance: ['responsive'], 696 | backgroundAttachment: ['responsive'], 697 | backgroundClip: ['responsive'], 698 | backgroundColor: ['responsive', 'hover', 'focus'], 699 | backgroundImage: ['responsive'], 700 | gradientColorStops: ['responsive', 'hover', 'focus'], 701 | backgroundOpacity: ['responsive', 'hover', 'focus'], 702 | backgroundPosition: ['responsive'], 703 | backgroundRepeat: ['responsive'], 704 | backgroundSize: ['responsive'], 705 | borderCollapse: ['responsive'], 706 | borderColor: ['responsive', 'hover', 'focus'], 707 | borderOpacity: ['responsive', 'hover', 'focus'], 708 | borderRadius: ['responsive'], 709 | borderStyle: ['responsive'], 710 | borderWidth: ['responsive'], 711 | boxShadow: ['responsive', 'hover', 'focus'], 712 | boxSizing: ['responsive'], 713 | container: ['responsive'], 714 | cursor: ['responsive'], 715 | display: ['responsive'], 716 | divideColor: ['responsive'], 717 | divideOpacity: ['responsive'], 718 | divideStyle: ['responsive'], 719 | divideWidth: ['responsive'], 720 | fill: ['responsive'], 721 | flex: ['responsive'], 722 | flexDirection: ['responsive'], 723 | flexGrow: ['responsive'], 724 | flexShrink: ['responsive'], 725 | flexWrap: ['responsive'], 726 | float: ['responsive'], 727 | clear: ['responsive'], 728 | fontFamily: ['responsive'], 729 | fontSize: ['responsive'], 730 | fontSmoothing: ['responsive'], 731 | fontStyle: ['responsive'], 732 | fontWeight: ['responsive', 'hover', 'focus'], 733 | height: ['responsive'], 734 | inset: ['responsive'], 735 | justifyContent: ['responsive'], 736 | letterSpacing: ['responsive'], 737 | lineHeight: ['responsive'], 738 | listStylePosition: ['responsive'], 739 | listStyleType: ['responsive'], 740 | margin: ['responsive'], 741 | maxHeight: ['responsive'], 742 | maxWidth: ['responsive'], 743 | minHeight: ['responsive'], 744 | minWidth: ['responsive'], 745 | objectFit: ['responsive'], 746 | objectPosition: ['responsive'], 747 | opacity: ['responsive', 'hover', 'focus'], 748 | order: ['responsive'], 749 | outline: ['responsive', 'focus'], 750 | overflow: ['responsive'], 751 | overscrollBehavior: ['responsive'], 752 | padding: ['responsive'], 753 | placeholderColor: ['responsive', 'focus'], 754 | placeholderOpacity: ['responsive', 'focus'], 755 | pointerEvents: ['responsive'], 756 | position: ['responsive'], 757 | resize: ['responsive'], 758 | space: ['responsive'], 759 | stroke: ['responsive'], 760 | strokeWidth: ['responsive'], 761 | tableLayout: ['responsive'], 762 | textAlign: ['responsive'], 763 | textColor: ['responsive', 'hover', 'focus'], 764 | textOpacity: ['responsive', 'hover', 'focus'], 765 | textDecoration: ['responsive', 'hover', 'focus'], 766 | textTransform: ['responsive'], 767 | userSelect: ['responsive'], 768 | verticalAlign: ['responsive'], 769 | visibility: ['responsive'], 770 | whitespace: ['responsive'], 771 | width: ['responsive'], 772 | wordBreak: ['responsive'], 773 | zIndex: ['responsive'], 774 | gap: ['responsive'], 775 | gridAutoFlow: ['responsive'], 776 | gridTemplateColumns: ['responsive'], 777 | gridColumn: ['responsive'], 778 | gridColumnStart: ['responsive'], 779 | gridColumnEnd: ['responsive'], 780 | gridTemplateRows: ['responsive'], 781 | gridRow: ['responsive'], 782 | gridRowStart: ['responsive'], 783 | gridRowEnd: ['responsive'], 784 | transform: ['responsive'], 785 | transformOrigin: ['responsive'], 786 | scale: ['responsive', 'hover', 'focus'], 787 | rotate: ['responsive', 'hover', 'focus'], 788 | translate: ['responsive', 'hover', 'focus'], 789 | skew: ['responsive', 'hover', 'focus'], 790 | transitionProperty: ['responsive'], 791 | transitionTimingFunction: ['responsive'], 792 | transitionDuration: ['responsive'], 793 | transitionDelay: ['responsive'], 794 | animation: ['responsive'], 795 | }, 796 | corePlugins: {}, 797 | plugins: [], 798 | } 799 | --------------------------------------------------------------------------------