├── .gitignore ├── .netlify └── state.json ├── README.md ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── GlobalStyle.ts ├── components │ ├── auth │ │ └── AuthProvider.tsx │ ├── common │ │ └── TextField.tsx │ ├── layout │ │ ├── Loading.tsx │ │ ├── Navbar.tsx │ │ ├── Ripple.tsx │ │ ├── SubNav.tsx │ │ └── SubSubNav.tsx │ ├── leftnavigation │ │ ├── LeftNav.tsx │ │ └── LeftNavSearch.tsx │ ├── post │ │ ├── Comment.tsx │ │ ├── CommentItemMenu.tsx │ │ ├── Comments.tsx │ │ └── Post.tsx │ ├── rightnavigation │ │ └── RightNav.tsx │ ├── style │ │ └── basicStyles.ts │ ├── subreddit │ │ ├── GettingMorePosts.tsx │ │ ├── NoMorePosts.tsx │ │ ├── PageIndicator.tsx │ │ ├── Subreddit.tsx │ │ └── SubredditPost.tsx │ └── user │ │ ├── User.tsx │ │ ├── UserComment.tsx │ │ ├── UserHeader.tsx │ │ ├── UserPosts.tsx │ │ └── UserTrophies.tsx ├── context │ ├── auth │ │ ├── AuthState.tsx │ │ ├── authContext.ts │ │ ├── authReducer.ts │ │ └── authTypes.ts │ ├── reddit │ │ ├── RedditState.tsx │ │ ├── redditContext.ts │ │ ├── redditReducer.ts │ │ └── redditTypes.ts │ ├── types.ts │ └── user │ │ ├── UserState.tsx │ │ ├── userContext.ts │ │ ├── userReducer.ts │ │ └── userTypes.ts ├── hooks │ └── useDebouncedRippleCleanup.ts ├── index.tsx ├── react-app-env.d.ts ├── redux │ ├── actions │ │ ├── loadingActions.ts │ │ └── themeActions.ts │ ├── reducers │ │ ├── index.ts │ │ ├── loadingReducer.ts │ │ └── themeReducer.ts │ └── store.ts ├── styled.d.ts ├── themes │ ├── MyThemeProvider.tsx │ └── my-theme.ts └── utils │ ├── customEase.ts │ ├── decodeHtml.ts │ ├── defaultSubredditsParser.ts │ ├── setAuthToken.ts │ ├── subredditParser.ts │ └── variants.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .env 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /.netlify/state.json: -------------------------------------------------------------------------------- 1 | { 2 | "siteId": "1863a944-b7ac-4c49-b77a-8fd700099b07" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Demo 2 | https://redditsyncr.netlify.app/ 3 | 4 | Built for mobile, this is a Reddit Sync clone that runs in the browser. This app is an alternative way to view one of the most popular websites on the internet, Reddit. All the data is fetched from Reddit's robust api. It features custom transitions made with FramerMotion and Styled Components. The state is handled by Redux, and React's context API. Credit for the design goes to the Reddit Sync team. 5 | 6 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 7 | 8 | ## Available Scripts 9 | 10 | In the project directory, you can run: 11 | 12 | ### `yarn start` 13 | 14 | Runs the app in the development mode.
15 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 16 | 17 | The page will reload if you make edits.
18 | You will also see any lint errors in the console. 19 | 20 | ### `yarn test` 21 | 22 | Launches the test runner in the interactive watch mode.
23 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 24 | 25 | ### `yarn build` 26 | 27 | Builds the app for production to the `build` folder.
28 | It correctly bundles React in production mode and optimizes the build for the best performance. 29 | 30 | The build is minified and the filenames include the hashes.
31 | Your app is ready to be deployed! 32 | 33 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 34 | 35 | ### `yarn eject` 36 | 37 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 38 | 39 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 40 | 41 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 42 | 43 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 44 | 45 | ## Learn More 46 | 47 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 48 | 49 | To learn React, check out the [React documentation](https://reactjs.org/). 50 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish="build" 3 | 4 | 5 | [[redirects]] 6 | from = "/*" 7 | to = "/index.html" 8 | status = 200 -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reddit-refactor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "@types/jest": "^24.0.0", 10 | "@types/node": "^12.12.47", 11 | "@types/qs": "^6.9.3", 12 | "@types/react": "^16.9.36", 13 | "@types/react-dom": "^16.9.8", 14 | "@types/react-redux": "^7.1.9", 15 | "@types/react-router-dom": "^5.1.5", 16 | "@types/styled-components": "^5.1.0", 17 | "@types/uuid": "^8.0.0", 18 | "axios": "^0.21.2", 19 | "framer-motion": "^1.11.1", 20 | "moment": "^2.26.0", 21 | "moment-timezone": "^0.5.31", 22 | "qs": "^6.9.4", 23 | "react": "^16.13.1", 24 | "react-dom": "^16.13.1", 25 | "react-intersection-observer": "^8.26.2", 26 | "react-moment": "^0.9.7", 27 | "react-redux": "^7.2.0", 28 | "react-router-dom": "^5.2.0", 29 | "react-scripts": "3.4.1", 30 | "react-swipeable": "^5.5.1", 31 | "redux": "^4.0.5", 32 | "redux-devtools-extension": "^2.13.8", 33 | "redux-thunk": "^2.3.0", 34 | "styled-components": "^5.1.1", 35 | "styled-normalize": "^8.0.7", 36 | "typescript": "~3.7.2", 37 | "uuid": "^8.1.0" 38 | }, 39 | "scripts": { 40 | "start": "react-scripts start", 41 | "build": "react-scripts build", 42 | "test": "react-scripts test", 43 | "eject": "react-scripts eject" 44 | }, 45 | "eslintConfig": { 46 | "extends": "react-app" 47 | }, 48 | "browserslist": { 49 | "production": [ 50 | ">0.2%", 51 | "not dead", 52 | "not op_mini all" 53 | ], 54 | "development": [ 55 | "last 1 chrome version", 56 | "last 1 firefox version", 57 | "last 1 safari version" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustinkiselbach/reddit-refactor/3cb8a79b4a39a4973fc0b90c242d46b0e4553c01/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 20 | 24 | 25 | RedditSyncr 26 | 27 | 28 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustinkiselbach/reddit-refactor/3cb8a79b4a39a4973fc0b90c242d46b0e4553c01/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dustinkiselbach/reddit-refactor/3cb8a79b4a39a4973fc0b90c242d46b0e4553c01/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "RedditSyncr", 3 | "name": "RedditSyncr", 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.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter as Router, Switch, Route } from 'react-router-dom' 3 | 4 | import AuthState from './context/auth/AuthState' 5 | import RedditState from './context/reddit/RedditState' 6 | import UserState from './context/user/UserState' 7 | 8 | import { GlobalStyle } from './GlobalStyle' 9 | 10 | import { Navbar } from './components/layout/Navbar' 11 | import { Subreddit } from './components/subreddit/Subreddit' 12 | import { Post } from './components/post/Post' 13 | 14 | import { store } from './redux/store' 15 | import { Provider } from 'react-redux' 16 | import { MyThemeProvider } from './themes/MyThemeProvider' 17 | import { User } from './components/user/User' 18 | import { AuthProvider } from './components/auth/AuthProvider' 19 | 20 | const App: React.FC = () => { 21 | return ( 22 | <> 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ) 50 | } 51 | 52 | export default App 53 | -------------------------------------------------------------------------------- /src/GlobalStyle.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components' 2 | import { normalize } from 'styled-normalize' 3 | 4 | export const GlobalStyle = createGlobalStyle` 5 | ${normalize} 6 | * { 7 | text-decoration: none; 8 | 9 | } 10 | 11 | html { 12 | box-sizing: border-box; 13 | -webkit-font-smoothing: antialiased; 14 | font-size: 12px; 15 | 16 | } 17 | 18 | body { 19 | font-family: 'Open Sans', sans-serif; 20 | font-weight: 400; 21 | background: ${props => props.theme.colors.backgroundColor}; 22 | color: ${props => props.theme.colors.textColor}; 23 | 24 | ul { 25 | list-style: none; 26 | margin: 0; 27 | padding: 0; 28 | } 29 | 30 | a { 31 | color: ${props => props.theme.colors.textColor}; 32 | } 33 | } 34 | 35 | ` 36 | -------------------------------------------------------------------------------- /src/components/auth/AuthProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import AuthContext from '../../context/auth/authContext' 3 | 4 | interface AuthProviderProps { 5 | children: React.ReactNode 6 | } 7 | 8 | export const AuthProvider: React.FC = ({ children }) => { 9 | const authContext = useContext(AuthContext) 10 | 11 | return <>{authContext.authenticated ? children :
} 12 | } 13 | -------------------------------------------------------------------------------- /src/components/common/TextField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | interface TextFieldProps { 5 | placeholder: string 6 | onChange: (e: React.ChangeEvent) => void 7 | value: string 8 | } 9 | 10 | export const TextField: React.FC = ({ 11 | placeholder, 12 | value, 13 | onChange 14 | }) => { 15 | return ( 16 | 22 | ) 23 | } 24 | 25 | const Input = styled.input` 26 | padding: 1rem 0; 27 | width: calc(100% - 2rem); 28 | background-color: ${props => props.theme.colors.backgroundColor}; 29 | border: none; 30 | border-bottom: 1px solid ${props => props.theme.colors.textColorFaded}; 31 | font-family: inherit; 32 | color: ${props => props.theme.colors.textColor}; 33 | font-weight: bold; 34 | overflow: visible; 35 | 36 | &::placeholder { 37 | color: ${props => props.theme.colors.textColorFaded}; 38 | } 39 | 40 | &:focus { 41 | outline: none; 42 | } 43 | ` 44 | -------------------------------------------------------------------------------- /src/components/layout/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | 4 | export const Loading: React.FC = () => { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | const Container = styled.div` 13 | height: 100vh; 14 | display: flex; 15 | align-items: center; 16 | justify-content: center; 17 | text-align: center; 18 | ` 19 | 20 | const rotate = keyframes` 21 | from { 22 | transform: rotate(0deg); 23 | } 24 | 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | ` 29 | 30 | const Spinner = styled.div` 31 | display: inline-block; 32 | width: 80px; 33 | height: 80px; 34 | 35 | &::after { 36 | content: ' '; 37 | display: block; 38 | width: 64px; 39 | height: 64px; 40 | margin: 8px; 41 | border-radius: 50%; 42 | border: 6px solid #fff; 43 | border-color: #fff transparent #fff transparent; 44 | animation: ${rotate} 0.76s linear infinite; 45 | } 46 | ` 47 | -------------------------------------------------------------------------------- /src/components/layout/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react' 2 | import styled from 'styled-components' 3 | import RedditContext from '../../context/reddit/redditContext' 4 | import UserContext from '../../context/user/userContext' 5 | import { SubNav } from './SubNav' 6 | import { AnimatePresence } from 'framer-motion' 7 | import { LeftNav } from '../leftnavigation/LeftNav' 8 | import { useHistory } from 'react-router-dom' 9 | import { useSwipeable } from 'react-swipeable' 10 | import { RightNav } from '../rightnavigation/RightNav' 11 | import { SubSubNav } from './SubSubNav' 12 | 13 | interface Props {} 14 | 15 | const postsOptions = ['hot', 'new', 'rising', 'top', 'controversial'] 16 | const commentsOptions = ['top', 'best', 'new', 'old', 'controversial', 'Q&A'] 17 | const userOptions = ['new', 'hot', 'top', 'controversial'] 18 | 19 | export const Navbar: React.FC = () => { 20 | const [prevScrollPos, setScrollPos] = useState(window.pageYOffset) 21 | const [visible, setVisible] = useState(true) 22 | const [showSort, setShowSort] = useState(false) 23 | const [showSubSort, setShowSubSort] = useState(false) 24 | const [sortLabel, setSortLabel] = useState(null) 25 | const [showLeft, setShowLeft] = useState(false) 26 | const [showRight, setShowRight] = useState(false) 27 | 28 | const handlers = useSwipeable({ 29 | onSwipedLeft: () => setShowLeft(false), 30 | onSwipedRight: () => setShowRight(false) 31 | }) 32 | 33 | const rightHandler = useSwipeable({ 34 | onSwipedRight: () => setShowLeft(true) 35 | }) 36 | 37 | const leftHandler = useSwipeable({ 38 | onSwipedLeft: () => setShowRight(true) 39 | }) 40 | 41 | let history = useHistory() 42 | 43 | const redditContext = useContext(RedditContext) 44 | const { 45 | trendingSubreddits, 46 | searchTerm, 47 | subreddit, 48 | subredditInfo, 49 | sortBy, 50 | sortByInterval, 51 | sortCommentsBy, 52 | defaultSubreddits, 53 | basicSubreddits, 54 | autocompleteSubreddits, 55 | post, 56 | clearPosts, 57 | changeSortBy, 58 | changeSearchTerm, 59 | changeSortCommentsBy, 60 | setSubreddit, 61 | subredditAutocomplete 62 | } = redditContext 63 | 64 | const userContext = useContext(UserContext) 65 | const { 66 | userName, 67 | sortUserContentBy, 68 | getUserPosts, 69 | changeSortUserContentBy 70 | } = userContext 71 | 72 | useEffect(() => { 73 | window.addEventListener('scroll', handleScroll) 74 | 75 | return () => window.removeEventListener('scroll', handleScroll) 76 | }) 77 | 78 | // Hide or show the menu. 79 | const handleScroll = () => { 80 | const currentScrollPos = window.pageYOffset 81 | const visible = prevScrollPos > currentScrollPos 82 | 83 | setScrollPos(currentScrollPos) 84 | setVisible(visible) 85 | } 86 | 87 | if (post) { 88 | return ( 89 | <> 90 | 120 | 121 | ) 122 | } else if (userName) { 123 | return ( 124 | <> 125 | 155 | 156 | ) 157 | } else { 158 | return ( 159 | <> 160 | 161 | 162 | 163 | {showLeft && defaultSubreddits && ( 164 |
165 | 175 |
176 | )} 177 |
178 | 179 | {showRight && ( 180 |
181 | {subredditInfo ? ( 182 | 187 | ) : ( 188 | 193 | )} 194 |
195 | )} 196 |
197 | 244 | 245 | ) 246 | } 247 | } 248 | 249 | const Nav = styled.nav<{ visible: boolean }>` 250 | display: flex; 251 | padding: 1rem 0; 252 | align-items: center; 253 | justify-content: space-evenly; 254 | position: fixed; 255 | width: calc(100%); 256 | background-color: ${props => props.theme.colors.backgroundColor}; 257 | box-shadow: ${props => props.theme.boxShadow}; 258 | transform: translateY(${props => (props.visible ? '0' : '-100px')}); 259 | transition: all 0.2s ease-in-out; 260 | z-index: 1; 261 | div { 262 | h2 { 263 | margin: 0.5rem 0; 264 | } 265 | label { 266 | font-weight: 300; 267 | text-transform: uppercase; 268 | } 269 | } 270 | ` 271 | const NavIcon = styled.div` 272 | border-radius: 100%; 273 | display: flex; 274 | align-items: center; 275 | justify-content: center; 276 | text-align: center; 277 | width: 3rem; 278 | height: 3rem; 279 | transition: all 0.2s ease-in-out; 280 | position: relative; 281 | 282 | span { 283 | font-weight: 300; 284 | font-size: 2.2rem; 285 | } 286 | 287 | &:active { 288 | background-color: ${props => props.theme.colors.navActive}; 289 | } 290 | ` 291 | 292 | const SwipeRight = styled.div` 293 | height: 100%; 294 | background-color: transparent; 295 | width: 15px; 296 | z-index: 3; 297 | position: fixed; 298 | ` 299 | 300 | const SwipeLeft = styled.div` 301 | height: 100%; 302 | background-color: transparent; 303 | width: 15px; 304 | z-index: 3; 305 | position: fixed; 306 | right: 0; 307 | ` 308 | -------------------------------------------------------------------------------- /src/components/layout/Ripple.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, MouseEvent } from 'react' 2 | import styled from 'styled-components' 3 | import { useDebouncedRippleCleanUp } from '../../hooks/useDebouncedRippleCleanup' 4 | 5 | interface RippleProps { 6 | duration?: number 7 | color?: string 8 | } 9 | 10 | interface NewRipple { 11 | x: number 12 | y: number 13 | size: number 14 | } 15 | 16 | export const Ripple: React.FC = ({ 17 | duration = 850, 18 | color = '#fff' 19 | }) => { 20 | const [rippleArray, setRippleArray] = useState([]) 21 | 22 | useDebouncedRippleCleanUp(rippleArray.length, duration, () => { 23 | setRippleArray([]) 24 | }) 25 | 26 | const addRipple = (event: MouseEvent) => { 27 | const rippleContainer = event.currentTarget.getBoundingClientRect() 28 | const size = 29 | rippleContainer.width > rippleContainer.height 30 | ? rippleContainer.width 31 | : rippleContainer.height 32 | 33 | const x = 34 | event.pageX - 35 | rippleContainer.x - 36 | rippleContainer.width / 2 - 37 | window.scrollX 38 | const y = 39 | event.pageY - 40 | rippleContainer.y - 41 | rippleContainer.width / 2 - 42 | window.scrollY 43 | const newRipple = { 44 | x, 45 | y, 46 | size 47 | } 48 | 49 | setRippleArray(prevState => [...prevState, newRipple]) 50 | } 51 | 52 | return ( 53 | 54 | {rippleArray.length > 0 && 55 | rippleArray.map((ripple, index) => { 56 | return ( 57 | 66 | ) 67 | })} 68 | 69 | ) 70 | } 71 | 72 | const RippleContainer = styled.div<{ color: string; duration: number }>` 73 | position: absolute; 74 | top: 0; 75 | right: 0; 76 | bottom: 0; 77 | left: 0; 78 | 79 | span { 80 | transform: scale(0); 81 | border-radius: 100%; 82 | position: absolute; 83 | opacity: 0.75; 84 | background-color: ${props => props.color}; 85 | animation-name: ripple; 86 | animation-duration: ${props => props.duration}ms; 87 | } 88 | 89 | @keyframes ripple { 90 | to { 91 | opacity: 0; 92 | transform: scale(2); 93 | } 94 | } 95 | ` 96 | -------------------------------------------------------------------------------- /src/components/layout/SubNav.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react' 2 | import styled from 'styled-components' 3 | import { motion } from 'framer-motion' 4 | import { customEase } from '../../utils/customEase' 5 | 6 | interface SubNavProps { 7 | changeSortBy: ((sortBy: string) => void) | undefined 8 | options: string[] 9 | subSubEnabled?: boolean 10 | setShowSubSort?: Dispatch> 11 | setSortLabel?: Dispatch> 12 | } 13 | 14 | export const SubNav: React.FC = ({ 15 | changeSortBy, 16 | options, 17 | subSubEnabled, 18 | setShowSubSort, 19 | setSortLabel 20 | }) => { 21 | const openSubSubMenu = (option: string) => { 22 | setShowSubSort!(true) 23 | setSortLabel!(option) 24 | } 25 | 26 | return ( 27 | 33 |
    34 | {options.map(option => ( 35 |
  • { 38 | subSubEnabled && (option === 'top' || option === 'controversial') 39 | ? // open subsub menu 40 | openSubSubMenu(option) 41 | : changeSortBy!(option) 42 | }} 43 | > 44 | {option} 45 | {subSubEnabled && option === 'top' && ( 46 | arrow_right 47 | )} 48 | {subSubEnabled && option === 'controversial' && ( 49 | arrow_right 50 | )} 51 |
  • 52 | ))} 53 |
54 |
55 | ) 56 | } 57 | 58 | const Menu = styled(motion.div)` 59 | position: absolute; 60 | width: 189px; 61 | text-align: left; 62 | top: 0; 63 | left: 0; 64 | z-index: 2; 65 | margin-top: -17px; 66 | margin-left: -60px; 67 | border-radius: 2px; 68 | background-color: ${props => props.theme.colors.subMenuColor}; 69 | box-shadow: ${props => props.theme.boxShadow}; 70 | overflow: hidden; 71 | 72 | ul { 73 | li { 74 | overflow: hidden; 75 | padding: 1rem; 76 | margin: 0.5rem 0; 77 | font-size: 1.5rem; 78 | position: relative; 79 | text-transform: capitalize; 80 | display: flex; 81 | justify-content: space-between; 82 | align-items: center; 83 | 84 | &:first-child { 85 | margin-top: 0; 86 | } 87 | &:last-child { 88 | margin-bottom: 0; 89 | } 90 | &::after { 91 | position: absolute; 92 | content: ''; 93 | display: inline-block; 94 | top: 50%; 95 | left: 50%; 96 | transform: translate(-50%, -50%); 97 | width: 5px; 98 | height: 5px; 99 | background-color: transparent; 100 | border-radius: 50px; 101 | transition: 0.2s all ease-in-out; 102 | z-index: -1; 103 | } 104 | &:active { 105 | &::after { 106 | background-color: ${props => props.theme.colors.navActive}; 107 | transform: scale(50); 108 | } 109 | } 110 | } 111 | } 112 | ` 113 | -------------------------------------------------------------------------------- /src/components/layout/SubSubNav.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react' 2 | import styled from 'styled-components' 3 | import { motion } from 'framer-motion' 4 | import { customEase } from '../../utils/customEase' 5 | 6 | interface SubSubNavProps { 7 | changeSortBy: ((sortBy: string, sortByInterval?: string) => void) | undefined 8 | setShowSubSort: Dispatch> 9 | setShowSort: Dispatch> 10 | sortLabel: string | null 11 | } 12 | 13 | const options = ['hour', 'day', 'week', 'month', 'year', 'all'] 14 | 15 | export const SubSubNav: React.FC = ({ 16 | changeSortBy, 17 | setShowSubSort, 18 | setShowSort, 19 | sortLabel 20 | }) => { 21 | return ( 22 | <> 23 | {sortLabel && ( 24 | 30 |
    31 |

    {sortLabel}

    32 | {options.map(option => ( 33 |
  • { 35 | changeSortBy!(sortLabel, option) 36 | setShowSubSort(false) 37 | setShowSort(false) 38 | }} 39 | key={option} 40 | > 41 | {option} 42 |
  • 43 | ))} 44 |
45 |
46 | )} 47 | 48 | ) 49 | } 50 | 51 | const Menu = styled(motion.div)` 52 | position: absolute; 53 | width: 189px; 54 | text-align: left; 55 | top: 0; 56 | left: 0; 57 | z-index: 2; 58 | margin-top: -17px; 59 | margin-left: -60px; 60 | border-radius: 2px; 61 | background-color: ${props => props.theme.colors.subMenuColor}; 62 | box-shadow: ${props => props.theme.boxShadow}; 63 | overflow: hidden; 64 | 65 | h4 { 66 | padding: 1rem; 67 | margin: 0.5rem 0; 68 | font-size: 1.5rem; 69 | position: relative; 70 | text-transform: capitalize; 71 | display: flex; 72 | justify-content: space-between; 73 | align-items: center; 74 | } 75 | 76 | ul { 77 | li { 78 | overflow: hidden; 79 | padding: 1rem; 80 | margin: 0.5rem 0; 81 | font-size: 1.5rem; 82 | position: relative; 83 | text-transform: capitalize; 84 | display: flex; 85 | justify-content: space-between; 86 | align-items: center; 87 | 88 | &:first-child { 89 | margin-top: 0; 90 | } 91 | &:last-child { 92 | margin-bottom: 0; 93 | } 94 | &::after { 95 | position: absolute; 96 | content: ''; 97 | display: inline-block; 98 | top: 50%; 99 | left: 50%; 100 | transform: translate(-50%, -50%); 101 | width: 5px; 102 | height: 5px; 103 | background-color: transparent; 104 | border-radius: 50px; 105 | transition: 0.2s all ease-in-out; 106 | z-index: -1; 107 | } 108 | &:active { 109 | &::after { 110 | background-color: ${props => props.theme.colors.navActive}; 111 | transform: scale(50); 112 | } 113 | } 114 | } 115 | } 116 | ` 117 | -------------------------------------------------------------------------------- /src/components/leftnavigation/LeftNav.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { connect } from 'react-redux' 3 | import styled from 'styled-components' 4 | import { DefaultSubreddit } from '../../context/reddit/redditTypes' 5 | import { motion } from 'framer-motion' 6 | import { customEase } from '../../utils/customEase' 7 | 8 | import { LeftNavSearch } from './LeftNavSearch' 9 | import { toggleTheme } from '../../redux/actions/themeActions' 10 | import { DarkenBackground } from '../style/basicStyles' 11 | import { Link } from 'react-router-dom' 12 | 13 | interface LeftNavProps { 14 | defaultSubreddits: DefaultSubreddit[] 15 | basicSubreddits: string[] 16 | autocompleteSubreddits?: DefaultSubreddit[] | null 17 | setSubreddit: (subreddit: string | null) => void 18 | setShowLeft: React.Dispatch 19 | subredditAutocomplete: (query: string) => void 20 | toggleTheme: any 21 | searchTerm?: string | null 22 | changeSearchTerm: (search: string) => void 23 | } 24 | 25 | const fallbackIconUrl = 26 | 'https://media.wired.com/photos/5954a1b05578bd7594c46869/master/w_550,c_limit/reddit-alien-red-st.jpg' 27 | 28 | const LeftNavPre: React.FC = ({ 29 | defaultSubreddits, 30 | autocompleteSubreddits, 31 | basicSubreddits, 32 | setSubreddit, 33 | setShowLeft, 34 | searchTerm, 35 | changeSearchTerm, 36 | subredditAutocomplete, 37 | toggleTheme 38 | }) => { 39 | useEffect(() => { 40 | document.body.style.overflow = 'hidden' 41 | 42 | return () => { 43 | document.body.style.overflow = 'scroll' 44 | } 45 | }, []) 46 | 47 | return ( 48 | <> 49 | 55 | 61 | 62 | 63 |

guest

64 |
65 | 66 | 67 | nights_stay{' '} 68 |

Night Mode

69 |
70 |
71 |
72 | 73 | 80 | 81 | { 83 | setShowLeft(false) 84 | subredditAutocomplete('') 85 | }} 86 | > 87 | {autocompleteSubreddits ? ( 88 | <> 89 | {autocompleteSubreddits.map(subreddit => ( 90 | setSubreddit(subreddit.name)} 93 | > 94 | 99 |

{subreddit.name}

100 |
101 | ))} 102 | {/* LINK TO GO TO USER */} 103 | 104 | 105 | 106 |

107 | /u/{searchTerm} 108 |

109 |
110 | 111 | ) : ( 112 | <> 113 | {basicSubreddits.map(subreddit => ( 114 | setSubreddit(subreddit)} 117 | > 118 | 119 |

{subreddit}

120 |
121 | ))} 122 | {defaultSubreddits.map(subreddit => ( 123 | setSubreddit(subreddit.name)} 126 | > 127 | 132 |

{subreddit.name}

133 |
134 | ))} 135 | 136 | )} 137 |
138 |
139 | 140 | ) 141 | } 142 | 143 | export const LeftNav = connect(null, { toggleTheme })(LeftNavPre) 144 | 145 | const LeftNavMenu = styled(motion.div)` 146 | background-color: ${props => props.theme.colors.backgroundColor}; 147 | position: fixed; 148 | width: 80%; 149 | height: 100%; 150 | z-index: 4; 151 | overflow-y: scroll; 152 | ` 153 | 154 | const LeftNavTop = styled.div`` 155 | 156 | const Me = styled.div` 157 | height: 100px; 158 | background-image: linear-gradient( 159 | 45deg, 160 | ${props => props.theme.colors.primaryColor}, 161 | ${props => props.theme.colors.secondaryColor} 162 | ); 163 | display: flex; 164 | flex-direction: column; 165 | justify-content: flex-end; 166 | padding: 0 1rem; 167 | ` 168 | 169 | const MeStuff = styled.ul`` 170 | 171 | const MeItem = styled.li` 172 | display: flex; 173 | align-items: center; 174 | position: relative; 175 | overflow: hidden; 176 | 177 | span { 178 | margin: 1rem; 179 | } 180 | 181 | &::after { 182 | position: absolute; 183 | content: ''; 184 | display: inline-block; 185 | top: 50%; 186 | left: 50%; 187 | transform: translate(-50%, -50%); 188 | width: 5px; 189 | height: 5px; 190 | background-color: transparent; 191 | border-radius: 50px; 192 | transition: 0.2s all ease-in-out; 193 | z-index: -1; 194 | } 195 | &:active { 196 | &::after { 197 | background-color: ${props => props.theme.colors.navActive}; 198 | transform: scale(100); 199 | } 200 | } 201 | ` 202 | 203 | const SubredditsList = styled.ul`` 204 | 205 | const SubredditItem = styled.li` 206 | display: flex; 207 | align-items: center; 208 | position: relative; 209 | overflow: hidden; 210 | 211 | &::after { 212 | position: absolute; 213 | content: ''; 214 | display: inline-block; 215 | top: 50%; 216 | left: 50%; 217 | transform: translate(-50%, -50%); 218 | width: 5px; 219 | height: 5px; 220 | background-color: transparent; 221 | border-radius: 50px; 222 | transition: 0.2s all ease-in-out; 223 | z-index: -1; 224 | } 225 | 226 | &:active { 227 | &::after { 228 | background-color: ${props => props.theme.colors.navActive}; 229 | transform: scale(100); 230 | } 231 | } 232 | ` 233 | 234 | const SubredditIcon = styled.div<{ icon: string }>` 235 | background-image: url(${props => props.icon}); 236 | background-size: cover; 237 | background-position: center; 238 | border-radius: 100%; 239 | 240 | width: 2.5rem; 241 | height: 2.5rem; 242 | 243 | margin: 1rem; 244 | ` 245 | const SearchContainer = styled.div` 246 | padding: 1rem; 247 | display: flex; 248 | align-items: center; 249 | border-top: 1px solid ${props => props.theme.colors.textColorFaded}; 250 | ` 251 | -------------------------------------------------------------------------------- /src/components/leftnavigation/LeftNavSearch.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { TextField } from '../common/TextField' 3 | import styled from 'styled-components' 4 | 5 | interface LeftNavSearchProps { 6 | placeholder: string 7 | setSubreddit: (subreddit: string | null) => void 8 | setShowLeft: React.Dispatch 9 | subredditAutocomplete: (query: string) => void 10 | changeSearchTerm: (search: string) => void 11 | } 12 | 13 | export const LeftNavSearch: React.FC = ({ 14 | placeholder, 15 | setSubreddit, 16 | setShowLeft, 17 | subredditAutocomplete, 18 | changeSearchTerm 19 | }) => { 20 | const [field, setField] = useState('') 21 | 22 | const onChange = (e: React.ChangeEvent) => { 23 | setField(e.target.value) 24 | subredditAutocomplete(e.target.value) 25 | changeSearchTerm(e.target.value) 26 | } 27 | 28 | const onSubmit = () => { 29 | setSubreddit(field) 30 | setShowLeft(false) 31 | } 32 | 33 | return ( 34 | <> 35 | 36 | {field.length > 0 && ( 37 | <> 38 | { 41 | setField('') 42 | subredditAutocomplete('') 43 | }} 44 | > 45 | close 46 | 47 | Go 48 | 49 | )} 50 | 51 | ) 52 | } 53 | 54 | const Icon = styled.span` 55 | margin-left: -2rem; 56 | color: ${props => props.theme.colors.textColorFaded}; 57 | ` 58 | const SubmitButton = styled.button` 59 | border: none; 60 | background-color: ${props => props.theme.colors.backgroundColor}; 61 | color: ${props => props.theme.colors.textColorFaded}; 62 | outline: none; 63 | ` 64 | -------------------------------------------------------------------------------- /src/components/post/Comment.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, Dispatch, SetStateAction } from 'react' 2 | import { CommentData, CommentMore } from '../../context/reddit/redditTypes' 3 | import { Comments } from './Comments' 4 | import styled from 'styled-components' 5 | import Moment from 'react-moment' 6 | import { motion } from 'framer-motion' 7 | import { childVariants } from '../../utils/variants' 8 | import { customEase } from '../../utils/customEase' 9 | import { decodeHTML } from '../../utils/decodeHtml' 10 | import { CommentItemMenu } from './CommentItemMenu' 11 | 12 | interface CommentProps { 13 | clickedId: string | null 14 | setClickedId: Dispatch> 15 | comment: CommentData | CommentMore 16 | number: number 17 | more?: boolean 18 | postName: string 19 | getMoreComments: (linkId: string, children: string[]) => Promise 20 | } 21 | 22 | const isComment = (variableToCheck: any): variableToCheck is CommentData => 23 | (variableToCheck as CommentData).kind === 't1' 24 | 25 | const colors = ['#de3e49', '#3e56de', '#d433ce', '#33d453', '#ed683b'] 26 | 27 | export const Comment: React.FC = ({ 28 | clickedId, 29 | setClickedId, 30 | comment, 31 | number, 32 | more, 33 | postName, 34 | getMoreComments 35 | }) => { 36 | const [loadMore, setLoadMore] = useState(false) 37 | 38 | const [moreComments, setMoreComments] = useState(null) 39 | 40 | const getMoreReplies = async (children: string[]) => { 41 | setLoadMore(true) 42 | const res = await getMoreComments(postName, children) 43 | setMoreComments(res) 44 | } 45 | // console.log(comment) 46 | // console.log(moreComments) 47 | 48 | if (isComment(comment)) { 49 | const { 50 | data: { 51 | author, 52 | author_flair_text, 53 | body_html, 54 | created_utc, 55 | distinguished, 56 | id, 57 | is_submitter, 58 | replies, 59 | score, 60 | score_hidden, 61 | stickied 62 | } 63 | } = comment 64 | 65 | return ( 66 | <> 67 | setClickedId(id)} 76 | clicked={clickedId === id} 77 | > 78 | 79 | 80 | 85 | {author} 86 | 87 | 88 | {author_flair_text && ( 89 | 90 | {author_flair_text} 91 | 92 | )} 93 | {distinguished && ( 94 | [{distinguished[0]}] 95 | )} 96 | 97 | {score_hidden ? '[score hidden]' : `${score} points`} 98 | 99 | 100 | 101 | 102 | {created_utc} 103 | 104 | 105 | 106 |
113 |
114 |
115 | {clickedId === id && } 116 | {/* recursivly calling replies */} 117 | {replies instanceof Object && ( 118 | 126 | )} 127 | 128 | ) 129 | } else { 130 | const { 131 | data: { count, children, id } 132 | } = comment 133 | 134 | if (id === '_') { 135 | return null 136 | } 137 | 138 | return ( 139 | <> 140 | {!moreComments ? ( 141 | getMoreReplies(children)} 150 | > 151 | {loadMore ? ( 152 | Loading . . . 153 | ) : ( 154 | View More ({count}) 155 | )} 156 | 157 | ) : ( 158 | <> 159 | {moreComments && 160 | moreComments.map((comment: CommentData, index: number) => ( 161 | <> 162 | 172 | 173 | ))} 174 | 175 | )} 176 | 177 | ) 178 | } 179 | } 180 | 181 | const CommentContainer = styled(motion.div)<{ 182 | labelColor: string 183 | clicked?: boolean 184 | more?: boolean 185 | }>` 186 | position: relative; 187 | margin: 1px 0; 188 | &:before { 189 | content: ''; 190 | width: 5px; 191 | height: 100%; 192 | background-color: ${props => props.labelColor}; 193 | position: absolute; 194 | margin-left: -0.5rem; 195 | } 196 | a { 197 | color: ${props => props.theme.colors.primaryColor}; 198 | text-decoration: underline; 199 | } 200 | color: ${props => 201 | props.more 202 | ? props.theme.colors.primaryColor 203 | : props.theme.colors.textColor}; 204 | 205 | background-color: ${props => props.clicked && props.theme.colors.highlight}; 206 | ` 207 | const CommentItem = styled.div` 208 | padding: 0.5rem; 209 | overflow-x: hidden; 210 | ` 211 | const CommentMeta = styled.ul` 212 | display: flex; 213 | align-items: center; 214 | color: ${props => props.theme.colors.textColorFaded}; 215 | font-size: 0.8rem; 216 | ` 217 | 218 | const CommentMetaItem = styled.li<{ 219 | isSubmitter?: boolean 220 | isSticked?: boolean 221 | isDistinguished?: boolean 222 | isFlair?: boolean 223 | }>` 224 | border-radius: 2.5px; 225 | margin-right: 0.4rem; 226 | 227 | ${props => { 228 | if (props.isSubmitter) { 229 | return ` 230 | padding: 0.2rem 0.4rem; 231 | background-color: #3e56de; 232 | color: ${props.theme.colors.textColor}; 233 | ` 234 | } else if (props.isSticked || props.isDistinguished) { 235 | return ` 236 | padding: 0.2rem 0.4rem; 237 | background-color: #33d453; 238 | color: ${props.theme.colors.textColor}; 239 | ` 240 | } else if (props.isFlair) { 241 | return ` 242 | color: ${props.theme.colors.primaryColor}; 243 | ` 244 | } 245 | }} 246 | ` 247 | -------------------------------------------------------------------------------- /src/components/post/CommentItemMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { motion } from 'framer-motion' 4 | import { customEase } from '../../utils/customEase' 5 | import { Link } from 'react-router-dom' 6 | 7 | interface CommentItemMenuProps { 8 | author: string 9 | } 10 | 11 | export const CommentItemMenu: React.FC = ({ author }) => { 12 | return ( 13 | 18 | arrow_upward 19 | arrow_downward 20 | star 21 | 22 | account_circle 23 | 24 | reply 25 | expand_less 26 | more_vert 27 | 28 | ) 29 | } 30 | 31 | const CommentMenuItemContainer = styled(motion.div)` 32 | background-color: ${props => props.theme.colors.subMenuColor}; 33 | width: 100%; 34 | display: flex; 35 | align-items: center; 36 | justify-content: space-evenly; 37 | ` 38 | 39 | const Icon = styled.span` 40 | padding: 1rem; 41 | ` 42 | -------------------------------------------------------------------------------- /src/components/post/Comments.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react' 2 | import { CommentData } from '../../context/reddit/redditTypes' 3 | import { Comment } from './Comment' 4 | 5 | interface CommentsProps { 6 | clickedId: string | null 7 | setClickedId: Dispatch> 8 | comments: CommentData[] | string 9 | number?: number 10 | postName: string 11 | getMoreComments: (linkId: string, children: string[]) => Promise 12 | } 13 | 14 | export const Comments: React.FC = ({ 15 | clickedId, 16 | setClickedId, 17 | comments, 18 | number = 0, 19 | postName, 20 | getMoreComments 21 | }) => { 22 | return ( 23 | <> 24 | {comments instanceof Object && ( 25 | <> 26 | {comments.map((comment, index) => ( 27 | 36 | ))} 37 | 38 | )} 39 | 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/components/post/Post.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react' 2 | import styled from 'styled-components' 3 | import { useSelector } from 'react-redux' 4 | import { RouteComponentProps } from 'react-router-dom' 5 | import RedditContext from '../../context/reddit/redditContext' 6 | import { SubredditPost } from '../subreddit/SubredditPost' 7 | import { Comments } from './Comments' 8 | import { motion } from 'framer-motion' 9 | import { parentVariants } from '../../utils/variants' 10 | import { customEase } from '../../utils/customEase' 11 | import { Container } from '../style/basicStyles' 12 | import { ReduxState } from '../../redux/store' 13 | import { Loading } from '../layout/Loading' 14 | 15 | interface PostProps 16 | extends RouteComponentProps<{ 17 | subreddit: string 18 | id: string 19 | title: string 20 | name: string 21 | }> {} 22 | 23 | export const Post: React.FC = ({ match }) => { 24 | const [clickedId, setClickedId] = useState(null) 25 | 26 | const state = useSelector((state: ReduxState) => state.loading) 27 | 28 | const { 29 | params: { subreddit, id, title, name } 30 | } = match 31 | 32 | const redditContext = useContext(RedditContext) 33 | const { 34 | post, 35 | comments, 36 | sortCommentsBy, 37 | clearPostDetail, 38 | clearCommentDetail, 39 | getPostDetail, 40 | getMoreComments 41 | } = redditContext 42 | 43 | useEffect(() => { 44 | getPostDetail!(`${subreddit}/comments/${id}/${title}`, name) 45 | 46 | return () => { 47 | clearPostDetail!() 48 | clearCommentDetail!() 49 | } 50 | }, [sortCommentsBy]) 51 | 52 | return ( 53 | <> 54 | {state.loading ? ( 55 | 56 | ) : ( 57 | 62 | {post && ( 63 | <> 64 | 65 | 66 | )} 67 | {comments && ( 68 | <> 69 | 74 | 81 | 82 | 83 | )} 84 | 85 | )} 86 | 87 | ) 88 | } 89 | 90 | const CommentsContainer = styled(motion.div)` 91 | box-shadow: ${props => props.theme.boxShadow}; 92 | ` 93 | -------------------------------------------------------------------------------- /src/components/rightnavigation/RightNav.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { motion } from 'framer-motion' 3 | import styled from 'styled-components' 4 | import { customEase } from '../../utils/customEase' 5 | import { DarkenBackground } from '../style/basicStyles' 6 | import { decodeHTML } from '../../utils/decodeHtml' 7 | import { SubredditInfo } from '../../context/reddit/redditTypes' 8 | 9 | interface RightNavProps { 10 | subredditInfo?: SubredditInfo 11 | trendingSubreddits?: string[] | null 12 | setSubreddit: (subreddit: string) => void 13 | setShowRight: any 14 | } 15 | 16 | export const RightNav: React.FC = ({ 17 | subredditInfo, 18 | setSubreddit, 19 | setShowRight, 20 | trendingSubreddits 21 | }) => { 22 | useEffect(() => { 23 | document.body.style.overflow = 'hidden' 24 | 25 | return () => { 26 | document.body.style.overflow = 'scroll' 27 | } 28 | }, []) 29 | 30 | if (subredditInfo) { 31 | const { 32 | data: { 33 | accounts_active, 34 | description_html, 35 | display_name_prefixed, 36 | icon_img, 37 | subscribers 38 | } 39 | } = subredditInfo 40 | 41 | return ( 42 | <> 43 | 49 | 55 | 56 | 57 | 58 |

{display_name_prefixed}

59 |
60 | {subscribers} subscribers - {accounts_active} active 61 |
62 |
63 |
64 | 65 |
68 | 69 | 70 | ) 71 | } else { 72 | return ( 73 | <> 74 | 80 | 86 | { 88 | setShowRight(false) 89 | setSubreddit('trendingsubreddits') 90 | }} 91 | > 92 | trending_up 93 |
Trending
94 |
95 | 96 | {trendingSubreddits && ( 97 | <> 98 | {trendingSubreddits.map(item => ( 99 | { 102 | setShowRight(false) 103 | setSubreddit(item) 104 | }} 105 | > 106 | /r/{item} 107 | 108 | ))} 109 | 110 | )} 111 | 112 |
113 | 114 | ) 115 | } 116 | } 117 | 118 | const RightNavMenu = styled(motion.div)` 119 | background-color: ${props => props.theme.colors.backgroundColor}; 120 | position: fixed; 121 | width: 80%; 122 | height: 100%; 123 | z-index: 4; 124 | overflow-y: scroll; 125 | right: 0; 126 | padding: 1rem; 127 | 128 | a { 129 | color: ${props => props.theme.colors.primaryColor}; 130 | text-decoration: underline; 131 | } 132 | ` 133 | const SubredditHeader = styled.div` 134 | display: flex; 135 | align-items: center; 136 | ` 137 | 138 | const SubredditHeaderInfo = styled.div` 139 | h4 { 140 | margin: 2px; 141 | } 142 | 143 | h5 { 144 | margin: 2px; 145 | color: ${props => props.theme.colors.textColorFaded}; 146 | } 147 | ` 148 | 149 | const Icon = styled.span` 150 | margin-right: 1rem; 151 | ` 152 | 153 | const TrendingButton = styled.div` 154 | background-color: ${props => props.theme.colors.subMenuColor}; 155 | display: flex; 156 | align-items: center; 157 | padding: 1rem; 158 | div { 159 | font-size: 1.2rem; 160 | } 161 | ` 162 | 163 | const TrendingSubreddits = styled.ul` 164 | margin: 1rem 0; 165 | ` 166 | 167 | const TrendingSubredditItem = styled.li` 168 | padding: 1rem; 169 | position: relative; 170 | overflow: hidden; 171 | 172 | &::after { 173 | position: absolute; 174 | content: ''; 175 | display: inline-block; 176 | top: 50%; 177 | left: 50%; 178 | transform: translate(-50%, -50%); 179 | width: 5px; 180 | height: 5px; 181 | background-color: transparent; 182 | border-radius: 50px; 183 | transition: 0.2s all ease-in-out; 184 | z-index: -1; 185 | } 186 | &:active { 187 | &::after { 188 | background-color: ${props => props.theme.colors.navActive}; 189 | transform: scale(100); 190 | } 191 | } 192 | ` 193 | 194 | const SubredditIcon = styled.div<{ icon: string }>` 195 | background-image: url(${props => props.icon}); 196 | background-size: cover; 197 | background-position: center; 198 | border-radius: 100%; 199 | 200 | width: 2.5rem; 201 | height: 2.5rem; 202 | 203 | margin: 1rem; 204 | ` 205 | -------------------------------------------------------------------------------- /src/components/style/basicStyles.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { motion } from 'framer-motion' 3 | 4 | export const Container = styled(motion.div)` 5 | padding: 6rem 1rem 1rem 1rem; 6 | ` 7 | 8 | export const DarkenBackground = styled(motion.div)` 9 | width: 100%; 10 | position: fixed; 11 | height: 100%; 12 | z-index: 2; 13 | background-color: rgba(0, 0, 0, 0.5); 14 | ` 15 | -------------------------------------------------------------------------------- /src/components/subreddit/GettingMorePosts.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | export const GettingMorePosts: React.FC = () => { 5 | return 6 | } 7 | 8 | const Loader = styled.div` 9 | width: 100%; 10 | height: 8px; 11 | background: #eaeaea; 12 | position: relative; 13 | overflow: hidden; 14 | display: flex; 15 | align-items: stretch; 16 | justify-content: flex-start; 17 | border-radius: 4px; 18 | 19 | &::before { 20 | content: ' '; 21 | display: block; 22 | position: absolute; 23 | width: 50%; 24 | height: 100%; 25 | background: ${props => props.theme.colors.primaryColor}; 26 | animation: 0.4s progressIndeterminate infinite; 27 | } 28 | 29 | @keyframes progressIndeterminate { 30 | from { 31 | width: 0; 32 | margin-left: 0; 33 | margin-right: 100%; 34 | } 35 | 36 | 50% { 37 | width: 100%; 38 | margin-left: 0; 39 | margin-right: 0; 40 | } 41 | 42 | to { 43 | width: 0; 44 | margin-left: 100%; 45 | margin-right: 0; 46 | } 47 | } 48 | ` 49 | -------------------------------------------------------------------------------- /src/components/subreddit/NoMorePosts.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | export const NoMorePosts: React.FC = () => { 5 | return No more posts were loaded 6 | } 7 | 8 | const MessageContainer = styled.div` 9 | background-color: ${props => props.theme.colors.subMenuColor}; 10 | padding: 1rem; 11 | width: calc(100%-2rem); 12 | text-align: center; 13 | ` 14 | -------------------------------------------------------------------------------- /src/components/subreddit/PageIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | interface PageIndicatorProps { 5 | index: number 6 | } 7 | 8 | export const PageIndicator: React.FC = ({ index }) => { 9 | return ( 10 | 11 | window.scrollTo(0, 0)}> 12 | keyboard_arrow_up 13 | {' '} 14 | Page {index + 1}{' '} 15 | window.scrollTo(0, document.body.scrollHeight)} 18 | > 19 | keyboard_arrow_down 20 | 21 | 22 | ) 23 | } 24 | 25 | const PageIndicatorContainer = styled.div` 26 | padding: 1rem; 27 | width: calc(100%-2rem); 28 | text-align: center; 29 | display: flex; 30 | align-items: center; 31 | justify-content: space-between; 32 | color: ${props => props.theme.colors.textColorFaded}; 33 | ` 34 | -------------------------------------------------------------------------------- /src/components/subreddit/Subreddit.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from 'react' 2 | import { useSelector } from 'react-redux' 3 | 4 | import RedditContext from '../../context/reddit/redditContext' 5 | 6 | import { SubredditPost } from './SubredditPost' 7 | import { Loading } from '../layout/Loading' 8 | 9 | import { useInView } from 'react-intersection-observer' 10 | import { PostData } from '../../context/reddit/redditTypes' 11 | import { NoMorePosts } from './NoMorePosts' 12 | import { GettingMorePosts } from './GettingMorePosts' 13 | import { PageIndicator } from './PageIndicator' 14 | 15 | import { parentVariants } from '../../utils/variants' 16 | import { Container } from '../style/basicStyles' 17 | import { ReduxState } from '../../redux/store' 18 | 19 | export const Subreddit: React.FC = () => { 20 | const [ref, inView] = useInView({ 21 | triggerOnce: true, 22 | rootMargin: '400px 0px' 23 | }) 24 | 25 | const state = useSelector((state: ReduxState) => state.loading) 26 | 27 | const redditContext = useContext(RedditContext) 28 | const { 29 | after, 30 | posts, 31 | subreddit, 32 | sortBy, 33 | getPosts, 34 | getSubredditInfo 35 | } = redditContext 36 | 37 | // useEffect(() => { 38 | // setSubreddit!(match.params.subreddit) 39 | // console.log('from 1') 40 | 41 | // return () => { 42 | // console.log('left') 43 | // } 44 | // }, []) 45 | 46 | // TODO fix this second page onward will load new posts 47 | useEffect(() => { 48 | if (subreddit && posts?.length === 0) { 49 | getPosts!() 50 | getSubredditInfo!() 51 | } 52 | }, [posts, subreddit, sortBy]) 53 | 54 | useEffect(() => { 55 | if (inView && after) { 56 | getPosts!() 57 | } 58 | }, [inView]) 59 | 60 | return ( 61 | <> 62 | {state.loading ? ( 63 | 64 | ) : ( 65 |
66 | 71 | {posts && posts.length > 0 && ( 72 | <> 73 | {posts.map((grouping, index) => ( 74 | 75 | {index !== 0 && } 76 | {grouping.map((post: PostData, index: number) => ( 77 | 78 | 79 | 80 | ))} 81 |
82 | 83 | ))} 84 | 85 | )} 86 | {!after ? : } 87 | 88 |
89 | )} 90 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/components/subreddit/SubredditPost.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, MouseEvent } from 'react' 2 | import styled from 'styled-components' 3 | import { Link } from 'react-router-dom' 4 | import Moment from 'react-moment' 5 | import { getPostType, getMedia } from '../../utils/subredditParser' 6 | import { Loading } from '../layout/Loading' 7 | 8 | import { motion, AnimatePresence } from 'framer-motion' 9 | import { customEase } from '../../utils/customEase' 10 | import { PostData } from '../../context/reddit/redditTypes' 11 | import { childVariants } from '../../utils/variants' 12 | 13 | interface SubredditPostProps { 14 | post: PostData 15 | detail?: boolean 16 | } 17 | 18 | export const SubredditPost: React.FC = ({ 19 | post, 20 | detail 21 | }) => { 22 | const [clicked, setClicked] = useState(false) 23 | const [gifLoading, setGifLoading] = useState(false) 24 | const [animateStart, setAnimateStart] = useState(0) 25 | 26 | const { 27 | data: { 28 | author, 29 | created_utc, 30 | domain, 31 | link_flair_text, 32 | name, 33 | num_comments, 34 | preview, 35 | permalink, 36 | score, 37 | selftext, 38 | selftext_html, 39 | subreddit, 40 | stickied, 41 | title, 42 | url 43 | } 44 | } = post 45 | 46 | const type = getPostType(post.data) 47 | const media = getMedia(post.data, type) 48 | 49 | const onClick = (e: MouseEvent) => { 50 | const smallMediaContainer = e.currentTarget.getBoundingClientRect() 51 | 52 | // 300px is rougly the size of how far the start 53 | // needs to move up 54 | setAnimateStart(smallMediaContainer.y - 300) 55 | if (document.body.style.overflow === 'hidden') { 56 | document.body.style.overflow = 'scroll' 57 | } else { 58 | document.body.style.overflow = 'hidden' 59 | } 60 | 61 | setClicked(!clicked) 62 | } 63 | 64 | const onLoadStart = () => { 65 | if (type === 'video:hosted' || type === 'video:outside') { 66 | setGifLoading(true) 67 | } 68 | } 69 | 70 | const onLoadEnd = () => { 71 | setGifLoading(false) 72 | } 73 | 74 | return ( 75 | 79 | 80 | {clicked && ( 81 | 89 | {type.split(':')[0] === 'video' && ( 90 | <> 91 | {gifLoading ? : null} 92 | 105 | )} 106 | 107 | {/* this is image preview if is a gif video or article with preview */} 108 | 109 | {type !== 'self' && preview && ( 110 | 114 | {(type === 'link:preview' || type === 'link:video') && ( 115 |
116 | {domain} 117 | 118 | {url} 119 | 120 |
121 | )} 122 |
123 | )} 124 |
125 | 126 | 127 |

128 | {title} 129 |

130 | 131 | {link_flair_text && ( 132 | <> 133 | {link_flair_text} 134 | •{' '} 135 | 136 | )} 137 | {author} •{' '} 138 | 139 | {created_utc} 140 | {' '} 141 | • 142 | {subreddit} • ( 143 | {domain}) 144 | 145 |
146 | {/* // text only link*/} 147 | {type === 'link' && ( 148 | 149 | open_in_browser 150 |
151 |
{domain}
152 | {url} 153 |
154 |
155 | )} 156 | 157 | {/* if this is a self post you need to show this on the post preview */} 158 | {type === 'self' && ( 159 | <> 160 | {detail ? ( 161 |
162 | ) : ( 163 | {selftext.split('\n')[0]} 164 | )} 165 | 166 | )} 167 | 168 | 169 |
    170 |
  • {score} points
  • 171 |
  • {num_comments} comments
  • 172 |
173 |
174 | 175 | 176 | ) 177 | } 178 | 179 | const Post = styled(motion.div)` 180 | margin: 0.5rem 0; 181 | position: relative; 182 | overflow: hidden; 183 | box-shadow: ${props => props.theme.boxShadow}; 184 | ` 185 | 186 | const PostPreview = styled.div`` 187 | 188 | const PreviewImage = styled.div<{ thumbnail: string }>` 189 | background-image: url(${props => props.thumbnail}); 190 | background-size: cover; 191 | background-position: center; 192 | height: 15rem; 193 | display: flex; 194 | align-items: flex-end; 195 | justify-content: flex-end; 196 | 197 | div { 198 | width: 100%; 199 | 200 | background-color: rgba(0, 0, 0, 0.5); 201 | display: flex; 202 | flex-direction: column; 203 | white-space: nowrap; 204 | span { 205 | display: inline-block; 206 | padding: 0.5rem; 207 | } 208 | a { 209 | padding: 0.5rem; 210 | display: inline-block; 211 | overflow: hidden; 212 | } 213 | } 214 | ` 215 | 216 | const PreviewText = styled.div` 217 | margin: 0.5rem 0; 218 | font-size: 1.1rem; 219 | font-weight: 300; 220 | ` 221 | 222 | const PreviewLink = styled.a` 223 | white-space: nowrap; 224 | display: grid; 225 | grid-template-columns: 6rem min-content; 226 | align-items: center; 227 | 228 | span { 229 | height: 4rem; 230 | width: 4rem; 231 | display: flex; 232 | justify-content: center; 233 | align-items: center; 234 | border-radius: 100%; 235 | border: 1px solid ${props => props.theme.colors.textColor}; 236 | 237 | margin: 1rem; 238 | } 239 | ` 240 | 241 | const PostTitle = styled.div<{ stickied: boolean }>` 242 | a { 243 | color: ${props => 244 | props.stickied 245 | ? props.theme.colors.primaryColor 246 | : props.theme.colors.textColor}; 247 | font-weight: ${props => (props.stickied ? 600 : 400)}; 248 | } 249 | ` 250 | 251 | const PostTitleLabel = styled.label` 252 | color: ${props => props.theme.colors.textColorFaded}; 253 | ` 254 | 255 | const PostTitleFlair = styled.span` 256 | color: ${props => props.theme.colors.secondaryColor}; 257 | ` 258 | 259 | const PostTitleSubreddit = styled.span` 260 | font-weight: 600; 261 | color: ${props => props.theme.colors.secondaryColor}; 262 | ` 263 | 264 | const PostFooter = styled.div` 265 | color: ${props => props.theme.colors.textColorFaded}; 266 | ul { 267 | padding: 1rem 0; 268 | } 269 | ` 270 | 271 | const Container = styled.div` 272 | padding: 0.5rem; 273 | font-weight: 300; 274 | ` 275 | 276 | const PicDetail = styled(motion.div)<{ thumbnail: string }>` 277 | z-index: 2; 278 | position: fixed; 279 | 280 | top: 0; 281 | left: 0; 282 | height: 100vh; 283 | width: 100%; 284 | background-color: ${props => props.theme.colors.backgroundColor}; 285 | background-image: url(${props => props.thumbnail}); 286 | background-size: contain; 287 | background-repeat: no-repeat; 288 | background-position: center center; 289 | overflow-y: hidden; 290 | ` 291 | -------------------------------------------------------------------------------- /src/components/user/User.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect } from 'react' 2 | import { useSelector } from 'react-redux' 3 | import UserContext from '../../context/user/userContext' 4 | import { RouteComponentProps } from 'react-router-dom' 5 | import { UserHeader } from './UserHeader' 6 | import { UserTrophies } from './UserTrophies' 7 | import { Container } from '../style/basicStyles' 8 | import { UserPosts } from './UserPosts' 9 | import { ReduxState } from '../../redux/store' 10 | import { Loading } from '../layout/Loading' 11 | import { useInView } from 'react-intersection-observer' 12 | import { parentVariants } from '../../utils/variants' 13 | import { motion } from 'framer-motion' 14 | 15 | interface UserProps 16 | extends RouteComponentProps<{ 17 | userName: string 18 | }> {} 19 | 20 | export const User: React.FC = ({ match }) => { 21 | const [inViewRef, inView] = useInView({ 22 | triggerOnce: true, 23 | rootMargin: '400px 0px' 24 | }) 25 | 26 | const state = useSelector((state: ReduxState) => state.loading) 27 | 28 | const userContext = useContext(UserContext) 29 | 30 | const { 31 | after, 32 | userData, 33 | userPosts, 34 | userTrophies, 35 | sortUserContentBy, 36 | getUserInfo, 37 | getUserPosts 38 | } = userContext 39 | 40 | useEffect(() => { 41 | getUserInfo!(match.params.userName) 42 | 43 | return () => { 44 | getUserInfo!(null) 45 | getUserPosts!(null) 46 | } 47 | }, []) 48 | 49 | useEffect(() => { 50 | if (userPosts?.length === 0) { 51 | getUserPosts!(match.params.userName) 52 | } 53 | }, [sortUserContentBy, userPosts]) 54 | 55 | useEffect(() => { 56 | if (inView && after) { 57 | getUserPosts!(match.params.userName) 58 | } 59 | }, [inView]) 60 | 61 | return ( 62 | 63 | {userData && userTrophies && ( 64 | <> 65 | 66 | 67 | 68 | )} 69 | {state.loading ? ( 70 | 71 | ) : ( 72 | 77 | {userPosts && ( 78 | 83 | )} 84 | 85 | )} 86 | 87 | ) 88 | } 89 | -------------------------------------------------------------------------------- /src/components/user/UserComment.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react' 2 | import { CommentData } from '../../context/reddit/redditTypes' 3 | import RedditContext from '../../context/reddit/redditContext' 4 | import styled from 'styled-components' 5 | import Moment from 'react-moment' 6 | import { Link } from 'react-router-dom' 7 | 8 | interface UserCommentProps { 9 | comment: CommentData 10 | } 11 | 12 | // quick fix to format the link to work seamlessly 13 | // with the way my post component works 14 | const fixPermalinkUrl = (permalink: string, linkId: string) => { 15 | let linkArr = permalink.split('/') 16 | linkArr[linkArr.length - 2] = linkId 17 | return linkArr.join('/') 18 | } 19 | 20 | export const UserComment: React.FC = ({ comment }) => { 21 | const redditContext = useContext(RedditContext) 22 | const { 23 | data: { 24 | author, 25 | body_html, 26 | created_utc, 27 | link_title, 28 | link_id, 29 | permalink, 30 | score, 31 | subreddit 32 | } 33 | } = comment 34 | 35 | return ( 36 | redditContext.clearPosts!()} 39 | > 40 | 41 | {link_title} 42 | 43 | {author} 44 | {score} points 45 | 46 | 47 | {created_utc} 48 | 49 | 50 | 51 | {subreddit} 52 | 53 |
54 | 55 | 56 | ) 57 | } 58 | 59 | const UserCommentPostTitle = styled.h4` 60 | font-style: italic; 61 | ` 62 | 63 | const UserCommentContainer = styled.div` 64 | position: relative; 65 | margin: 1px 0; 66 | padding: 0.5rem; 67 | overflow-x: hidden; 68 | 69 | a { 70 | color: ${props => props.theme.colors.primaryColor}; 71 | text-decoration: underline; 72 | } 73 | color: ${props => props.theme.colors.textColor}; 74 | ` 75 | const UserCommentMeta = styled.ul` 76 | display: flex; 77 | align-items: center; 78 | color: ${props => props.theme.colors.textColorFaded}; 79 | font-size: 0.8rem; 80 | ` 81 | 82 | const UserCommentMetaItem = styled.li` 83 | border-radius: 2.5px; 84 | margin-right: 0.4rem; 85 | ` 86 | -------------------------------------------------------------------------------- /src/components/user/UserHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { UserData } from '../../context/user/userTypes' 3 | import styled from 'styled-components' 4 | import Moment from 'react-moment' 5 | 6 | interface UserHeaderProps { 7 | userData: UserData 8 | } 9 | 10 | export const UserHeader: React.FC = ({ userData }) => { 11 | const { 12 | data: { comment_karma, created_utc, name, link_karma } 13 | } = userData 14 | 15 | return ( 16 | 17 | 18 |

{link_karma}

19 | 20 |
21 | 22 |

{comment_karma}

23 | 24 |
25 | 26 |

27 | redditor since{' '} 28 | 29 | {created_utc} 30 | 31 |

32 |

33 | joined on{' '} 34 | 35 | {created_utc} 36 | 37 |

38 |
39 |
+ Friends
40 |
41 | ) 42 | } 43 | 44 | const UserDataContainer = styled.div` 45 | display: grid; 46 | grid-template-columns: 1fr 1fr; 47 | align-items: center; 48 | justify-items: center; 49 | ` 50 | const UserKarma = styled.div` 51 | padding: 1rem; 52 | 53 | h2 { 54 | font-size: 2rem; 55 | text-align: center; 56 | margin: 2px; 57 | } 58 | 59 | label { 60 | text-transform: uppercase; 61 | color: ${props => props.theme.colors.textColorFaded}; 62 | } 63 | ` 64 | const UserDates = styled.div` 65 | padding: 1rem; 66 | p { 67 | color: ${props => props.theme.colors.textColorFaded}; 68 | margin: 2px; 69 | } 70 | ` 71 | -------------------------------------------------------------------------------- /src/components/user/UserPosts.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { CommentData, PostData } from '../../context/reddit/redditTypes' 3 | import { SubredditPost } from '../subreddit/SubredditPost' 4 | import { UserComment } from './UserComment' 5 | import { PageIndicator } from '../subreddit/PageIndicator' 6 | import { NoMorePosts } from '../subreddit/NoMorePosts' 7 | import { GettingMorePosts } from '../subreddit/GettingMorePosts' 8 | import { motion } from 'framer-motion' 9 | import { childVariants } from '../../utils/variants' 10 | import { customEase } from '../../utils/customEase' 11 | 12 | interface UserPostsProps { 13 | after: string | null 14 | inViewRef: (node?: Element | null) => void 15 | userPosts: (PostData | CommentData)[][] 16 | } 17 | 18 | export const UserPosts: React.FC = ({ 19 | after, 20 | inViewRef, 21 | userPosts 22 | }) => { 23 | return ( 24 | <> 25 | {userPosts.map((userPostArr, index) => ( 26 | 27 | {index !== 0 && } 28 | {userPostArr.map((userPost, index2) => ( 29 | 34 | {userPost.kind === 't1' ? ( 35 | 36 | ) : ( 37 | 38 | )} 39 | 40 | ))} 41 |
42 | 43 | ))} 44 | {!after ? : } 45 | 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/components/user/UserTrophies.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { TrophyList, Trophy } from '../../context/user/userTypes' 3 | import styled from 'styled-components' 4 | 5 | interface UserTrophiesProps { 6 | userTrophies: TrophyList 7 | } 8 | 9 | export const UserTrophies: React.FC = ({ userTrophies }) => { 10 | const { 11 | data: { trophies } 12 | } = userTrophies 13 | 14 | return ( 15 | 16 | {trophies.map((trophy: Trophy, index: number) => ( 17 | 18 | 19 | 20 | 21 | ))} 22 | 23 | ) 24 | } 25 | 26 | const TrophyCase = styled.div` 27 | display: flex; 28 | overflow-x: scroll; 29 | ` 30 | const TrophyItem = styled.div` 31 | margin: 0.5rem; 32 | display: grid; 33 | grid-template-rows: 1fr max-content; 34 | align-items: center; 35 | justify-items: center; 36 | 37 | img { 38 | margin: 0.5rem; 39 | } 40 | 41 | label { 42 | color: ${props => props.theme.colors.textColorFaded}; 43 | white-space: nowrap; 44 | } 45 | ` 46 | -------------------------------------------------------------------------------- /src/context/auth/AuthState.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useReducer } from 'react' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import AuthContext from './authContext' 4 | import authReducer from './authReducer' 5 | 6 | import { AUTHENTICATE } from '../types' 7 | import { Props, State } from './authTypes' 8 | import axios, { AxiosRequestConfig } from 'axios' 9 | import qs from 'qs' 10 | import moment from 'moment' 11 | import { setAuthToken } from '../../utils/setAuthToken' 12 | 13 | // const redditAuthUrl = `https://www.reddit.com/api/v1/authorize?client_id=${process.env.REACT_APP_CLIENT_ID}&response_type=code&state=12345678&redirect_uri=http://localhost:3000&scope=identity` 14 | const AuthState: React.FC = ({ children }) => { 15 | const initialState: State = { 16 | authenticated: false, 17 | test: 'test', 18 | stateToken: uuidv4() 19 | } 20 | 21 | const [state, dispatch] = useReducer(authReducer, initialState) 22 | 23 | useEffect(() => { 24 | const currentTime = moment() 25 | 26 | if ( 27 | localStorage.getItem('token') && 28 | moment(localStorage.getItem('exp')) > currentTime 29 | ) { 30 | setAuthToken(`bearer ${localStorage.getItem('token')}`) 31 | setAuthenticated() 32 | } else if ( 33 | localStorage.getItem('token') && 34 | moment(localStorage.getItem('exp')) < currentTime 35 | ) { 36 | localStorage.clear() 37 | applicationOnlyAuth() 38 | } else { 39 | applicationOnlyAuth() 40 | } 41 | }, []) 42 | 43 | const applicationOnlyAuth = async () => { 44 | const body = { 45 | grant_type: 'client_credentials' 46 | } 47 | 48 | const config: AxiosRequestConfig = { 49 | headers: { 50 | 'Content-Type': 'application/x-www-form-urlencoded' 51 | }, 52 | auth: { 53 | username: process.env.REACT_APP_CLIENT_ID!, 54 | password: process.env.REACT_APP_SECRET_KEY! 55 | } 56 | } 57 | 58 | try { 59 | const res = await axios.post( 60 | 'https://www.reddit.com/api/v1/access_token', 61 | qs.stringify(body), 62 | config 63 | ) 64 | console.log(res.data) 65 | 66 | const exp = moment().add(res.data.expires_in, 's') 67 | 68 | localStorage.setItem('token', res.data.access_token) 69 | localStorage.setItem('exp', exp.toString()) 70 | 71 | setAuthToken(`bearer ${res.data.access_token}`) 72 | setAuthenticated() 73 | } catch (err) { 74 | throw err 75 | } 76 | } 77 | 78 | const setAuthenticated = () => { 79 | dispatch({ type: AUTHENTICATE, payload: true }) 80 | } 81 | 82 | return ( 83 | 89 | {children} 90 | 91 | ) 92 | } 93 | 94 | export default AuthState 95 | -------------------------------------------------------------------------------- /src/context/auth/authContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { State } from './authTypes' 3 | 4 | const activitiesContext = createContext({}) 5 | 6 | export default activitiesContext 7 | -------------------------------------------------------------------------------- /src/context/auth/authReducer.ts: -------------------------------------------------------------------------------- 1 | import { TEST_TYPE, AUTHENTICATE } from '../types' 2 | import { State, AllActions } from './authTypes' 3 | 4 | export default (state: State, action: AllActions) => { 5 | switch (action.type) { 6 | case TEST_TYPE: { 7 | return { 8 | ...state, 9 | test: 'tested' 10 | } 11 | } 12 | case AUTHENTICATE: { 13 | return { 14 | ...state, 15 | authenticated: action.payload 16 | } 17 | } 18 | default: 19 | return state 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/context/auth/authTypes.ts: -------------------------------------------------------------------------------- 1 | export interface Props { 2 | children: React.ReactNode 3 | } 4 | 5 | export type State = { 6 | authenticated?: boolean 7 | test?: string 8 | stateToken?: string 9 | tryTest?: () => void 10 | } 11 | 12 | type ActionInterface = { 13 | type: T 14 | payload: U 15 | } 16 | 17 | export type AllActions = 18 | | ActionInterface<'TEST_TYPE', null> 19 | | ActionInterface<'AUTHENTICATE', boolean> 20 | -------------------------------------------------------------------------------- /src/context/reddit/RedditState.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useReducer } from 'react' 2 | import RedditContext from './redditContext' 3 | import redditReducer from './redditReducer' 4 | 5 | import axios from 'axios' 6 | 7 | import { 8 | GET_POSTS, 9 | CLEAR_POSTS, 10 | TEST_TYPE, 11 | SET_SUBREDDIT, 12 | GET_DEFAULT_SUBREDDITS, 13 | FILTER_POST_FROM_POSTS, 14 | CHANGE_SORT_BY, 15 | CHANGE_SORT_BY_INTERVAL, 16 | SET_AFTER, 17 | GET_POST_DETAIL, 18 | CLEAR_POST_DETAIL, 19 | SUBREDDIT_AUTOCOMPLETE, 20 | CHANGE_SORT_COMMENTS_BY, 21 | GET_SUBREDDIT_INFO, 22 | GET_POST_ON_REFRESH, 23 | CLEAR_COMMENT_DETAIL, 24 | GET_TRENDING_SUBREDDITS, 25 | CLEAR_SUBREDDIT_INFO, 26 | CHANGE_SEARCH_TERM 27 | } from '../types' 28 | import { Props } from './redditTypes' 29 | import { defaultSubredditsParser } from '../../utils/defaultSubredditsParser' 30 | import { connect } from 'react-redux' 31 | import { setLoading } from '../../redux/actions/loadingActions' 32 | import activitiesContext from '../auth/authContext' 33 | 34 | // subreddit 35 | // https://www.reddit.com/api/info.json?id={subreddit_id} 36 | // detail 37 | // https://www.reddit.com/{permalink} 38 | // defaults 39 | // https://www.reddit.com/subreddits/default.json 40 | 41 | const RedditState: React.FC = ({ children, setLoading }) => { 42 | const initialState = { 43 | trendingSubreddits: null, 44 | subreddit: 'all', 45 | subredditInfo: null, 46 | defaultSubreddits: null, 47 | autocompleteSubreddits: null, 48 | searchTerm: null, 49 | sortBy: 'hot', 50 | sortByInterval: null, 51 | sortCommentsBy: 'suggested', 52 | posts: [], 53 | post: null, 54 | comments: null, 55 | after: null, 56 | basicSubreddits: ['all', 'popular'] 57 | } 58 | 59 | const [state, dispatch] = useReducer(redditReducer, initialState) 60 | 61 | useEffect(() => { 62 | getDefaultSubreddits() 63 | getTrendingSubreddits() 64 | }, []) 65 | 66 | const getPosts = async () => { 67 | if (!state.after) { 68 | setLoading() 69 | } 70 | try { 71 | const res = await axios.get( 72 | `https://oauth.reddit.com/r/${state.subreddit}/${state.sortBy}.json?raw_json=1&after=${state.after}&t=${state.sortByInterval}` 73 | ) 74 | 75 | console.log(res) 76 | 77 | dispatch({ 78 | type: GET_POSTS, 79 | payload: res.data.data.children 80 | }) 81 | 82 | dispatch({ 83 | type: SET_AFTER, 84 | payload: res.data.data.after 85 | }) 86 | } catch (err) { 87 | throw err 88 | } 89 | if (!state.after) { 90 | setLoading() 91 | } 92 | } 93 | 94 | const clearPosts = () => { 95 | dispatch({ type: CLEAR_POSTS, payload: null }) 96 | } 97 | 98 | const filterPostFromPosts = (name: string) => { 99 | dispatch({ type: FILTER_POST_FROM_POSTS, payload: name }) 100 | } 101 | 102 | const getPostDetail = async (permalink: string, name: string) => { 103 | // FASTER IF USER ALREADY HAS POSTS LOADED 104 | if (state.posts!.length > 0) { 105 | filterPostFromPosts(name) 106 | try { 107 | const res = await axios.get( 108 | `https://oauth.reddit.com/r/${permalink}.json?raw_json=1&sort=${state.sortCommentsBy}` 109 | ) 110 | 111 | dispatch({ 112 | type: GET_POST_DETAIL, 113 | payload: res.data[1].data.children 114 | }) 115 | } catch (err) { 116 | throw err 117 | } 118 | } else { 119 | // SLOWER BUT WILL WORK IF PAGE IS RELOADED 120 | // GET THE SUBREDDIT FROM THE URL 121 | setSubreddit(permalink.split('/')[0]) 122 | setLoading() 123 | try { 124 | const res = await axios.get( 125 | `https://oauth.reddit.com/r/${permalink}.json?raw_json=1&sort=${state.sortCommentsBy}` 126 | ) 127 | 128 | dispatch({ 129 | type: GET_POST_DETAIL, 130 | payload: res.data[1].data.children 131 | }) 132 | dispatch({ 133 | type: GET_POST_ON_REFRESH, 134 | payload: res.data[0].data.children[0] 135 | }) 136 | setLoading() 137 | } catch (err) { 138 | throw err 139 | } 140 | } 141 | } 142 | 143 | const getMoreComments = async (linkId: string, children: string[]) => { 144 | try { 145 | const res = await axios.get( 146 | `https://oauth.reddit.com/api/morechildren?api_type=json&link_id=${linkId}&children=${children.join()}` 147 | ) 148 | return res.data.json.data.things 149 | } catch (err) { 150 | throw err 151 | } 152 | } 153 | 154 | const getDefaultSubreddits = async () => { 155 | try { 156 | const res = await axios.get( 157 | 'https://oauth.reddit.com/subreddits/default.json' 158 | ) 159 | 160 | dispatch({ 161 | type: GET_DEFAULT_SUBREDDITS, 162 | payload: defaultSubredditsParser(res.data.data.children) 163 | }) 164 | } catch (err) { 165 | throw err 166 | } 167 | } 168 | 169 | // using fetch because this endpoint won't accept headers 170 | const getTrendingSubreddits = async () => { 171 | try { 172 | const data = await ( 173 | await fetch('https://www.reddit.com/api/trending_subreddits.json') 174 | ).json() 175 | dispatch({ type: GET_TRENDING_SUBREDDITS, payload: data.subreddit_names }) 176 | } catch (err) { 177 | throw err 178 | } 179 | } 180 | 181 | const subredditAutocomplete = async (query: string) => { 182 | if (query.length === 0) { 183 | dispatch({ 184 | type: SUBREDDIT_AUTOCOMPLETE, 185 | payload: null 186 | }) 187 | } else { 188 | try { 189 | const res = await axios.get( 190 | `https://oauth.reddit.com/api/subreddit_autocomplete_v2?query=${query}&include_over_18=true&include_profiles=false` 191 | ) 192 | dispatch({ 193 | type: SUBREDDIT_AUTOCOMPLETE, 194 | payload: defaultSubredditsParser(res.data.data.children) 195 | }) 196 | } catch (err) { 197 | throw err 198 | } 199 | } 200 | } 201 | 202 | const getSubredditInfo = async () => { 203 | if (state.subreddit && state.basicSubreddits) { 204 | if (state.basicSubreddits.includes(state.subreddit)) { 205 | dispatch({ 206 | type: CLEAR_SUBREDDIT_INFO, 207 | payload: null 208 | }) 209 | } else { 210 | try { 211 | const res = await axios.get( 212 | `https://oauth.reddit.com/r/${state.subreddit}/about` 213 | ) 214 | 215 | dispatch({ 216 | type: GET_SUBREDDIT_INFO, 217 | payload: res.data 218 | }) 219 | } catch (err) { 220 | throw err 221 | } 222 | } 223 | } 224 | } 225 | const subredditSearch = async (q: string) => { 226 | try { 227 | const res = await axios.post( 228 | `https://oauth.reddit.com/api/search_subreddits?query=${q}&include_over_18` 229 | ) 230 | console.log(res) 231 | } catch (err) { 232 | throw err 233 | } 234 | } 235 | 236 | const clearPostDetail = () => { 237 | dispatch({ type: CLEAR_POST_DETAIL, payload: null }) 238 | } 239 | 240 | const clearCommentDetail = () => { 241 | dispatch({ type: CLEAR_COMMENT_DETAIL, payload: null }) 242 | } 243 | 244 | const setSubreddit = (subreddit: string | null) => { 245 | dispatch({ type: SET_SUBREDDIT, payload: subreddit }) 246 | } 247 | 248 | // const setLoading = () => { 249 | // dispatch({ type: SET_LOADING, payload: null }) 250 | // } 251 | 252 | const changeSortBy = (sortBy: string, sortByInterval?: string) => { 253 | dispatch({ type: CHANGE_SORT_BY, payload: sortBy }) 254 | if (sortByInterval) { 255 | dispatch({ type: CHANGE_SORT_BY_INTERVAL, payload: sortByInterval }) 256 | } else { 257 | dispatch({ type: CHANGE_SORT_BY_INTERVAL, payload: null }) 258 | } 259 | } 260 | const changeSortCommentsBy = (sortCommentsBy: string) => { 261 | dispatch({ type: CHANGE_SORT_COMMENTS_BY, payload: sortCommentsBy }) 262 | } 263 | 264 | const changeSearchTerm = (search: string) => { 265 | dispatch({ type: CHANGE_SEARCH_TERM, payload: search }) 266 | } 267 | 268 | const tryTest = () => { 269 | dispatch({ type: TEST_TYPE, payload: null }) 270 | } 271 | 272 | return ( 273 | 304 | {children} 305 | 306 | ) 307 | } 308 | 309 | export default connect(null, { setLoading })(RedditState) 310 | -------------------------------------------------------------------------------- /src/context/reddit/redditContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { State } from './redditTypes' 3 | 4 | const redditContext = createContext({}) 5 | 6 | export default redditContext 7 | -------------------------------------------------------------------------------- /src/context/reddit/redditReducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GET_POSTS, 3 | CLEAR_POSTS, 4 | TEST_TYPE, 5 | SET_SUBREDDIT, 6 | GET_DEFAULT_SUBREDDITS, 7 | CHANGE_SORT_BY, 8 | SET_AFTER, 9 | GET_POST_DETAIL, 10 | CLEAR_POST_DETAIL, 11 | CLEAR_COMMENT_DETAIL, 12 | FILTER_POST_FROM_POSTS, 13 | SUBREDDIT_AUTOCOMPLETE, 14 | CHANGE_SORT_COMMENTS_BY, 15 | GET_SUBREDDIT_INFO, 16 | CHANGE_SORT_BY_INTERVAL, 17 | GET_POST_ON_REFRESH, 18 | GET_TRENDING_SUBREDDITS, 19 | CLEAR_SUBREDDIT_INFO, 20 | CHANGE_SEARCH_TERM 21 | } from '../types' 22 | import { State, AllActions, PostData } from './redditTypes' 23 | 24 | export default (state: State, action: AllActions): State => { 25 | switch (action.type) { 26 | case TEST_TYPE: { 27 | return { 28 | ...state 29 | // posts: action.payload 30 | } 31 | } 32 | case GET_POSTS: { 33 | return { 34 | ...state, 35 | posts: [...state.posts!, action.payload] 36 | } 37 | } 38 | case CLEAR_POSTS: { 39 | return { 40 | ...state, 41 | posts: [], 42 | after: null 43 | } 44 | } 45 | case GET_POST_DETAIL: { 46 | return { 47 | ...state, 48 | comments: action.payload 49 | } 50 | } 51 | case GET_POST_ON_REFRESH: { 52 | return { 53 | ...state, 54 | post: action.payload 55 | } 56 | } 57 | case GET_SUBREDDIT_INFO: { 58 | return { 59 | ...state, 60 | subredditInfo: action.payload 61 | } 62 | } 63 | case FILTER_POST_FROM_POSTS: { 64 | return { 65 | ...state, 66 | post: state.posts!.map(postArr => 67 | postArr.filter((post: PostData) => post.data.name === action.payload) 68 | )[state.posts!.length - 1][0] 69 | } 70 | } 71 | case CLEAR_POST_DETAIL: { 72 | return { 73 | ...state, 74 | post: null 75 | } 76 | } 77 | case CLEAR_SUBREDDIT_INFO: { 78 | return { 79 | ...state, 80 | subredditInfo: null 81 | } 82 | } 83 | case CLEAR_COMMENT_DETAIL: { 84 | return { 85 | ...state, 86 | comments: null 87 | } 88 | } 89 | 90 | case SET_SUBREDDIT: { 91 | return { 92 | ...state, 93 | subreddit: action.payload, 94 | after: null, 95 | posts: [] 96 | } 97 | } 98 | case GET_DEFAULT_SUBREDDITS: { 99 | return { 100 | ...state, 101 | defaultSubreddits: action.payload 102 | } 103 | } 104 | case GET_TRENDING_SUBREDDITS: { 105 | return { 106 | ...state, 107 | trendingSubreddits: action.payload 108 | } 109 | } 110 | case SUBREDDIT_AUTOCOMPLETE: { 111 | return { 112 | ...state, 113 | autocompleteSubreddits: action.payload 114 | } 115 | } 116 | case CHANGE_SORT_BY: { 117 | return { 118 | ...state, 119 | sortBy: action.payload, 120 | after: null, 121 | posts: [] 122 | } 123 | } 124 | case CHANGE_SORT_BY_INTERVAL: { 125 | return { 126 | ...state, 127 | sortByInterval: action.payload 128 | } 129 | } 130 | case CHANGE_SORT_COMMENTS_BY: { 131 | return { 132 | ...state, 133 | sortCommentsBy: action.payload 134 | } 135 | } 136 | case CHANGE_SEARCH_TERM: { 137 | return { 138 | ...state, 139 | searchTerm: action.payload 140 | } 141 | } 142 | case SET_AFTER: { 143 | return { 144 | ...state, 145 | after: action.payload 146 | } 147 | } 148 | 149 | default: 150 | return state 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/context/reddit/redditTypes.ts: -------------------------------------------------------------------------------- 1 | import { Action } from 'redux' 2 | 3 | export interface Props { 4 | children: React.ReactNode 5 | setLoading: () => void 6 | } 7 | 8 | export interface PostData { 9 | data: { 10 | author: string 11 | created_utc: number 12 | domain: string 13 | link_flair_text: string 14 | name: string 15 | num_comments: number 16 | preview: { 17 | images: { 18 | source: { 19 | url: string 20 | } 21 | }[] 22 | reddit_video_preview: any 23 | } 24 | permalink: string 25 | post_hint: string 26 | score: number 27 | selftext: string 28 | selftext_html: string 29 | secure_media: any 30 | subreddit: string 31 | stickied: boolean 32 | thumbnail: string 33 | title: string 34 | url: string 35 | } 36 | kind: 't3' 37 | } 38 | 39 | export interface CommentData { 40 | data: { 41 | author: string 42 | author_flair_text: string | null 43 | body: string 44 | body_html: string 45 | children: CommentData[] | string 46 | created_utc: number 47 | depth: number 48 | distinguished: string | null 49 | id: string 50 | is_submitter: boolean 51 | link_title: string 52 | link_permalink: string 53 | link_id: string 54 | name: string 55 | parent_id: string 56 | permalink: string 57 | replies: CommentData | string 58 | score: number 59 | score_hidden: boolean 60 | 61 | stickied: boolean 62 | subreddit: string 63 | } 64 | kind: 't1' 65 | } 66 | 67 | export interface CommentMore { 68 | data: { 69 | children: string[] 70 | count: number 71 | depth: number 72 | id: string 73 | name: string 74 | parent_id: string 75 | } 76 | kind: 'more' 77 | } 78 | 79 | export interface SubredditInfo { 80 | data: { 81 | accounts_active: number 82 | created_utc: number 83 | description_html: string 84 | display_name: string 85 | display_name_prefixed: string 86 | header_img: string 87 | icon_img: string 88 | primary_color: string 89 | subscribers: number 90 | title: string 91 | user_is_subscriber: boolean 92 | } 93 | kind: 't5' 94 | } 95 | 96 | export type State = { 97 | trendingSubreddits?: null | string[] 98 | searchTerm?: null | string 99 | subreddit?: null | string 100 | subredditInfo?: SubredditInfo | null 101 | sortBy?: string 102 | sortByInterval?: string | null 103 | sortCommentsBy?: string 104 | posts?: PostData[][] 105 | post?: PostData | null 106 | comments?: CommentData[] | null 107 | defaultSubreddits?: null | DefaultSubreddit[] 108 | autocompleteSubreddits?: null | DefaultSubreddit[] 109 | after?: string | null 110 | basicSubreddits?: string[] 111 | clearPostDetail?: () => void 112 | clearCommentDetail?: () => void 113 | tryTest?: () => void 114 | getPosts?: () => Promise 115 | clearPosts?: () => void 116 | getPostDetail?: (permalink: string, name: string) => void 117 | getMoreComments?: (linkId: string, children: string[]) => Promise 118 | setSubreddit?: (subreddit: string | null) => void 119 | getSubredditInfo?: () => void 120 | subredditAutocomplete?: (query: string) => void 121 | changeSortBy?: (sortBy: string, sortByInterval?: string) => void 122 | changeSortCommentsBy?: (sortCommentsBy: string) => void 123 | setLoading?: () => void 124 | changeSearchTerm?: (search: string) => void 125 | } 126 | 127 | type ActionInterface = { 128 | type: T 129 | payload: U 130 | } 131 | 132 | export type DefaultSubreddit = { name: string; icon: string } 133 | 134 | export type AllActions = 135 | | ActionInterface<'TEST_TYPE', null> 136 | | ActionInterface<'GET_POSTS', PostData[]> 137 | | ActionInterface<'GET_POST_DETAIL', CommentData[]> 138 | | ActionInterface<'GET_POST_ON_REFRESH', PostData> 139 | | ActionInterface<'SET_SUBREDDIT', string | null> 140 | | ActionInterface<'CHANGE_SORT_BY', string> 141 | | ActionInterface<'CHANGE_SORT_BY_INTERVAL', string | null> 142 | | ActionInterface<'CHANGE_SORT_COMMENTS_BY', string> 143 | | ActionInterface<'GET_DEFAULT_SUBREDDITS', DefaultSubreddit[]> 144 | | ActionInterface<'GET_SUBREDDIT_INFO', SubredditInfo> 145 | | ActionInterface<'SET_AFTER', string | null> 146 | | ActionInterface<'CLEAR_POST_DETAIL', null> 147 | | ActionInterface<'FILTER_POST_FROM_POSTS', string> 148 | | ActionInterface<'SUBREDDIT_AUTOCOMPLETE', DefaultSubreddit[] | null> 149 | | ActionInterface<'CLEAR_POSTS', null> 150 | | ActionInterface<'CLEAR_COMMENT_DETAIL', null> 151 | | ActionInterface<'CLEAR_SUBREDDIT_INFO', null> 152 | | ActionInterface<'GET_TRENDING_SUBREDDITS', string[]> 153 | | ActionInterface<'CHANGE_SEARCH_TERM', string | null> 154 | -------------------------------------------------------------------------------- /src/context/types.ts: -------------------------------------------------------------------------------- 1 | // AUTH REDUCER TYPES 2 | export const TEST_TYPE = 'TEST_TYPE' 3 | export const AUTHENTICATE = 'AUTHENTICATE' 4 | // REDDIT REDUCER TYPES 5 | export const GET_POSTS = 'GET_POSTS' 6 | export const CLEAR_POSTS = 'CLEAR_POSTS' 7 | export const GET_POST_DETAIL = 'GET_POST_DETAIL' 8 | export const GET_POST_ON_REFRESH = 'GET_POST_ON_REFRESH' 9 | export const FILTER_POST_FROM_POSTS = 'FILTER_POST_FROM_POSTS' 10 | export const CLEAR_POST_DETAIL = 'CLEAR_POST_DETAIL' 11 | export const CLEAR_COMMENT_DETAIL = 'CLEAR_COMMENT_DETAIL' 12 | export const CLEAR_SUBREDDIT_INFO = 'CLEAR_SUBREDDIT_INFO' 13 | export const SET_SUBREDDIT = 'SET_SUBREDDIT' 14 | export const GET_DEFAULT_SUBREDDITS = 'GET_DEFAULT_SUBREDDITS' 15 | export const GET_TRENDING_SUBREDDITS = 'GET_TRENDING_SUBREDDITS' 16 | export const GET_SUBREDDIT_INFO = 'GET_SUBREDDIT_INFO' 17 | export const SUBREDDIT_AUTOCOMPLETE = 'SUBREDDIT_AUTOCOMPLETE' 18 | export const SET_LOADING = 'SET_LOADING' 19 | export const CHANGE_SORT_BY = 'CHANGE_SORT_BY' 20 | export const CHANGE_SORT_BY_INTERVAL = 'CHANGE_SORT_BY_INTERVAL' 21 | export const CHANGE_SORT_COMMENTS_BY = 'CHANGE_SORT_COMMENTS_BY' 22 | export const CHANGE_SEARCH_TERM = 'CHANGE_SEARCH_TERM' 23 | export const SET_AFTER = 'SET_AFTER' 24 | // USER REDUCER TYPES 25 | export const GET_USER_ABOUT = 'GET_USER_ABOUT' 26 | export const GET_USER_TROPHIES = 'GET_USER_TROPHIES' 27 | export const GET_USERNAME = 'GET_USERNAME' 28 | export const GET_USER_POSTS = 'GET_USER_POSTS' 29 | export const CLEAR_USER_POSTS = 'CLEAR_USER_POSTS' 30 | export const CLEAR_USER_INFO = 'CLEAR_USER_INFO' 31 | export const CHANGE_SORT_USER_CONTENT_BY = 'CHANGE_SORT_USER_CONTENT_BY' 32 | -------------------------------------------------------------------------------- /src/context/user/UserState.tsx: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from 'react' 2 | 3 | import UserContext from './userContext' 4 | import userReducer from './userReducer' 5 | import axios from 'axios' 6 | import { 7 | GET_USER_TROPHIES, 8 | GET_USER_ABOUT, 9 | GET_USERNAME, 10 | GET_USER_POSTS, 11 | CLEAR_USER_INFO, 12 | CHANGE_SORT_USER_CONTENT_BY, 13 | CLEAR_USER_POSTS, 14 | SET_AFTER 15 | } from '../types' 16 | import { setLoading } from '../../redux/actions/loadingActions' 17 | import { connect } from 'react-redux' 18 | 19 | interface Props { 20 | children: React.ReactNode 21 | setLoading: () => void 22 | } 23 | 24 | const UserState: React.FC = ({ children, setLoading }) => { 25 | const initialState = { 26 | after: null, 27 | loading: false, 28 | test: 'test', 29 | userName: null, 30 | userData: null, 31 | userTrophies: null, 32 | userPosts: [], 33 | sortUserContentBy: 'new' 34 | } 35 | 36 | const [state, dispatch] = useReducer(userReducer, initialState) 37 | 38 | const getUserTrophies = async (userName: string) => { 39 | try { 40 | const res = await axios.get( 41 | `https://oauth.reddit.com/api/v1/user/${userName}/trophies` 42 | ) 43 | console.log(res) 44 | dispatch({ type: GET_USER_TROPHIES, payload: res.data }) 45 | } catch (err) { 46 | throw err 47 | } 48 | } 49 | 50 | const getUserAbout = async (userName: string) => { 51 | try { 52 | const res = await axios.get( 53 | `https://oauth.reddit.com/user/${userName}/about` 54 | ) 55 | console.log(res) 56 | dispatch({ type: GET_USER_ABOUT, payload: res.data }) 57 | } catch (err) { 58 | throw err 59 | } 60 | } 61 | 62 | const getUserPosts = async (userName: string | null) => { 63 | // if there is an username fetch data else clear 64 | if (userName) { 65 | if (!state.after) { 66 | setLoading() 67 | } 68 | try { 69 | const res = await axios.get( 70 | `https://oauth.reddit.com/user/${userName}/overview?raw_json=1&sort=${state.sortUserContentBy}&after=${state.after}` 71 | ) 72 | 73 | console.log(res) 74 | 75 | dispatch({ type: SET_AFTER, payload: res.data.data.after }) 76 | dispatch({ type: GET_USER_POSTS, payload: res.data.data.children }) 77 | } catch (err) { 78 | throw err 79 | } 80 | if (!state.after) { 81 | setLoading() 82 | } 83 | } else { 84 | dispatch({ type: CLEAR_USER_POSTS, payload: null }) 85 | } 86 | } 87 | 88 | const getUserName = (userName: string) => { 89 | dispatch({ type: GET_USERNAME, payload: userName }) 90 | } 91 | 92 | const clearUserInfo = () => { 93 | dispatch({ type: CLEAR_USER_INFO, payload: null }) 94 | } 95 | 96 | const getUserInfo = (userName: string | null) => { 97 | // setLoading 98 | if (userName) { 99 | getUserName(userName) 100 | getUserAbout(userName) 101 | getUserTrophies(userName) 102 | } else { 103 | clearUserInfo() 104 | } 105 | } 106 | 107 | const changeSortUserContentBy = (sortBy: string) => { 108 | dispatch({ type: CHANGE_SORT_USER_CONTENT_BY, payload: sortBy }) 109 | } 110 | 111 | return ( 112 | 125 | {children} 126 | 127 | ) 128 | } 129 | 130 | export default connect(null, { setLoading })(UserState) 131 | -------------------------------------------------------------------------------- /src/context/user/userContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | import { State } from './userTypes' 3 | 4 | const activitiesContext = createContext({}) 5 | 6 | export default activitiesContext 7 | -------------------------------------------------------------------------------- /src/context/user/userReducer.ts: -------------------------------------------------------------------------------- 1 | import { State } from '../user/userTypes' 2 | import { AllActions } from '../user/userTypes' 3 | import { 4 | GET_USER_TROPHIES, 5 | GET_USER_ABOUT, 6 | GET_USERNAME, 7 | CLEAR_USER_INFO, 8 | CHANGE_SORT_USER_CONTENT_BY, 9 | GET_USER_POSTS, 10 | CLEAR_USER_POSTS, 11 | SET_AFTER 12 | } from '../types' 13 | 14 | export default (state: State, action: AllActions) => { 15 | switch (action.type) { 16 | case GET_USER_TROPHIES: { 17 | return { 18 | ...state, 19 | userTrophies: action.payload 20 | } 21 | } 22 | case GET_USER_ABOUT: { 23 | return { 24 | ...state, 25 | userData: action.payload 26 | } 27 | } 28 | case GET_USER_POSTS: { 29 | return { 30 | ...state, 31 | userPosts: [...state.userPosts!, action.payload] 32 | } 33 | } 34 | case GET_USERNAME: { 35 | return { 36 | ...state, 37 | userName: action.payload 38 | } 39 | } 40 | case CHANGE_SORT_USER_CONTENT_BY: { 41 | return { 42 | ...state, 43 | sortUserContentBy: action.payload, 44 | after: null, 45 | userPosts: [] 46 | } 47 | } 48 | case CLEAR_USER_POSTS: { 49 | return { 50 | ...state, 51 | userPosts: [], 52 | after: null 53 | } 54 | } 55 | case CLEAR_USER_INFO: { 56 | return { 57 | ...state, 58 | after: null, 59 | userTrophies: null, 60 | userData: null, 61 | userName: null 62 | } 63 | } 64 | case SET_AFTER: { 65 | return { 66 | ...state, 67 | after: action.payload 68 | } 69 | } 70 | default: 71 | return state 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/context/user/userTypes.ts: -------------------------------------------------------------------------------- 1 | import { PostData, CommentData } from '../reddit/redditTypes' 2 | 3 | export type State = { 4 | after?: string | null 5 | test?: string 6 | loading?: boolean 7 | userData?: UserData | null 8 | userPosts?: (PostData | CommentData)[][] 9 | userTrophies?: TrophyList | null 10 | userName?: string | null 11 | sortUserContentBy?: string 12 | getUserInfo?: (userName: string | null) => void 13 | getUserPosts?: (userName: string | null) => void 14 | changeSortUserContentBy?: (sortBy: string) => void 15 | } 16 | 17 | export interface Trophy { 18 | data: { 19 | award_id: string | null 20 | description: string | null 21 | icon_40: string 22 | icon_70: string 23 | id: string 24 | name: string 25 | url: string | null 26 | } 27 | kind: 't6' 28 | } 29 | 30 | export interface TrophyList { 31 | data: { 32 | trophies: Trophy[] 33 | } 34 | kind: 'TrophyList' 35 | } 36 | 37 | export interface UserData { 38 | data: { 39 | comment_karma: number 40 | created_utc: number 41 | name: string 42 | link_karma: number 43 | subreddit: any 44 | } 45 | kind: 't2' 46 | } 47 | 48 | type ActionInterface = { 49 | type: T 50 | payload: U 51 | } 52 | 53 | export type AllActions = 54 | | ActionInterface<'TEST_TYPE', null> 55 | | ActionInterface<'GET_USER_TROPHIES', TrophyList> 56 | | ActionInterface<'GET_USER_ABOUT', UserData> 57 | | ActionInterface<'GET_USERNAME', string | null> 58 | | ActionInterface<'CLEAR_USER_INFO', null> 59 | | ActionInterface<'CHANGE_SORT_USER_CONTENT_BY', string> 60 | | ActionInterface<'GET_USER_POSTS', (CommentData | PostData)[]> 61 | | ActionInterface<'CLEAR_USER_POSTS', null> 62 | | ActionInterface<'SET_AFTER', string> 63 | -------------------------------------------------------------------------------- /src/hooks/useDebouncedRippleCleanup.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react' 2 | 3 | export const useDebouncedRippleCleanUp = ( 4 | rippleCount: number, 5 | duration: number, 6 | cleanUpFunction: () => void 7 | ) => { 8 | useLayoutEffect(() => { 9 | let bounce: any = null 10 | if (rippleCount > 0) { 11 | clearTimeout(bounce) 12 | 13 | bounce = setTimeout(() => { 14 | cleanUpFunction() 15 | clearTimeout(bounce) 16 | }, duration * 4) 17 | } 18 | 19 | return () => clearTimeout(bounce) 20 | }, [rippleCount, duration, cleanUpFunction]) 21 | } 22 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root') 10 | ) 11 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/redux/actions/loadingActions.ts: -------------------------------------------------------------------------------- 1 | import { SET_LOADING } from '../../context/types' 2 | 3 | export const setLoading = () => { 4 | return { 5 | type: SET_LOADING 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/redux/actions/themeActions.ts: -------------------------------------------------------------------------------- 1 | import { TOGGLE_THEME } from '../reducers/themeReducer' 2 | 3 | export const toggleTheme = () => { 4 | return { 5 | type: TOGGLE_THEME 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/redux/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { themeReducer } from './themeReducer' 3 | import { loadingReducer } from './loadingReducer' 4 | 5 | export const rootReducer = combineReducers({ 6 | theme: themeReducer, 7 | loading: loadingReducer 8 | }) 9 | -------------------------------------------------------------------------------- /src/redux/reducers/loadingReducer.ts: -------------------------------------------------------------------------------- 1 | export type LoadingState = { 2 | loading: boolean 3 | } 4 | 5 | const initialState = { 6 | loading: false 7 | } 8 | 9 | export const SET_LOADING = 'SET_LOADING' 10 | 11 | export const loadingReducer = ( 12 | state: LoadingState = initialState, 13 | action: { type: 'SET_LOADING' } 14 | ) => { 15 | switch (action.type) { 16 | case SET_LOADING: 17 | return { 18 | ...state, 19 | loading: state.loading ? false : true 20 | } 21 | default: 22 | return state 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/redux/reducers/themeReducer.ts: -------------------------------------------------------------------------------- 1 | export type ThemeState = { 2 | palette: string 3 | } 4 | 5 | const initialState = { 6 | palette: 'dark' 7 | } 8 | 9 | export const TOGGLE_THEME = 'TOGGLE_THEME' 10 | 11 | export const themeReducer = ( 12 | state: ThemeState = initialState, 13 | action: { type: 'TOGGLE_THEME' } 14 | ) => { 15 | switch (action.type) { 16 | case TOGGLE_THEME: 17 | return { 18 | ...state, 19 | palette: state.palette === 'dark' ? 'light' : 'dark' 20 | } 21 | default: 22 | return state 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/redux/store.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import { composeWithDevTools } from 'redux-devtools-extension' 3 | import thunk from 'redux-thunk' 4 | import { rootReducer } from './reducers' 5 | 6 | import { ThemeState } from './reducers/themeReducer' 7 | import { LoadingState } from './reducers/loadingReducer' 8 | 9 | export type ReduxState = { 10 | theme: ThemeState 11 | loading: LoadingState 12 | } 13 | 14 | const initialState = {} 15 | 16 | const middleware = [thunk] 17 | 18 | export const store = createStore( 19 | rootReducer, 20 | initialState, 21 | composeWithDevTools(applyMiddleware(...middleware)) 22 | ) 23 | -------------------------------------------------------------------------------- /src/styled.d.ts: -------------------------------------------------------------------------------- 1 | // import original module declarations 2 | import 'styled-components' 3 | 4 | // and extend them! 5 | declare module 'styled-components' { 6 | export interface DefaultTheme { 7 | colors: { 8 | backgroundColor: string 9 | textColor: string 10 | textColorFaded: string 11 | primaryColor: string 12 | secondaryColor: string 13 | subMenuColor: string 14 | navActive: string 15 | highlight: string 16 | } 17 | boxShadow: string 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/themes/MyThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { ThemeProvider } from 'styled-components' 4 | import { darkTheme, lightTheme } from './my-theme' 5 | import { ReduxState } from '../redux/store' 6 | 7 | interface MyThemeProviderProps { 8 | children: React.ReactNode 9 | } 10 | 11 | export const MyThemeProvider: React.FC = ({ 12 | children 13 | }) => { 14 | const state = useSelector((state: ReduxState) => state.theme) 15 | 16 | return ( 17 | 18 | {children} 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /src/themes/my-theme.ts: -------------------------------------------------------------------------------- 1 | // my-theme.ts 2 | import { DefaultTheme } from 'styled-components' 3 | 4 | const darkTheme: DefaultTheme = { 5 | colors: { 6 | backgroundColor: 'rgb(18, 18, 18)', 7 | textColor: 'rgb(255, 255, 255)', 8 | textColorFaded: 'rgba(255, 255, 255, 0.6)', 9 | primaryColor: '#FFB300', 10 | secondaryColor: '#6600ff', 11 | subMenuColor: 'rgb(42, 42, 42)', 12 | navActive: 'rgba(255,255,255,0.2)', 13 | highlight: '#264061' 14 | }, 15 | boxShadow: 'none' 16 | } 17 | 18 | const lightTheme: DefaultTheme = { 19 | colors: { 20 | backgroundColor: 'rgb(255, 255, 255)', 21 | textColor: 'rgb(18, 18, 18)', 22 | textColorFaded: 'rgba(18, 18, 18, 0.6)', 23 | primaryColor: '#FFD54F', 24 | secondaryColor: '#b587ff', 25 | subMenuColor: 'rgb(255, 255, 255)', 26 | navActive: 'rgba(18,18,18,0.2)', 27 | highlight: '#65A6F9' 28 | }, 29 | boxShadow: '0 0.4rem 0.4rem rgba(0,0,0,0.2)' 30 | } 31 | 32 | export { darkTheme, lightTheme } 33 | -------------------------------------------------------------------------------- /src/utils/customEase.ts: -------------------------------------------------------------------------------- 1 | export const customEase = [0.6, 0.05, -0.01, 0.9] 2 | -------------------------------------------------------------------------------- /src/utils/decodeHtml.ts: -------------------------------------------------------------------------------- 1 | export const decodeHTML = (str: string) => { 2 | let txt = document.createElement('textarea') 3 | txt.innerHTML = str 4 | return txt.value 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/defaultSubredditsParser.ts: -------------------------------------------------------------------------------- 1 | interface Ikeys { 2 | name: string 3 | icon: string 4 | } 5 | 6 | export const defaultSubredditsParser = (subreddits: []) => { 7 | return subreddits.map( 8 | (subreddit: any) => 9 | ({ 10 | name: subreddit.data.display_name, 11 | icon: subreddit.data.icon_img 12 | } as Ikeys) 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/setAuthToken.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios' 2 | import moment from 'moment' 3 | import qs from 'qs' 4 | 5 | export const setAuthToken = (token?: string) => { 6 | if (token) { 7 | // apply to every request 8 | 9 | axios.defaults.headers.common['Authorization'] = token 10 | } else { 11 | // Delete auth header 12 | delete axios.defaults.headers.common['Authorization'] 13 | } 14 | } 15 | 16 | axios.interceptors.request.use( 17 | config => { 18 | let exp = localStorage.getItem('exp') 19 | let now = new Date() 20 | 21 | if (exp) { 22 | if (new Date(exp) < now) { 23 | console.log('expired') 24 | localStorage.clear() 25 | return applicationOnlyAuth().then((token: string) => { 26 | setAuthToken(token) 27 | return Promise.resolve(config) 28 | }) 29 | } 30 | } 31 | return config 32 | }, 33 | err => { 34 | return Promise.reject(err) 35 | } 36 | ) 37 | 38 | const applicationOnlyAuth = async () => { 39 | const body = { 40 | grant_type: 'client_credentials' 41 | } 42 | 43 | const config: AxiosRequestConfig = { 44 | headers: { 45 | 'Content-Type': 'application/x-www-form-urlencoded' 46 | }, 47 | auth: { 48 | username: process.env.REACT_APP_CLIENT_ID!, 49 | password: process.env.REACT_APP_SECRET_KEY! 50 | } 51 | } 52 | 53 | try { 54 | const res = await axios.post( 55 | 'https://www.reddit.com/api/v1/access_token', 56 | qs.stringify(body), 57 | config 58 | ) 59 | 60 | const exp = moment().add(res.data.expires_in, 's') 61 | 62 | localStorage.setItem('token', res.data.access_token) 63 | localStorage.setItem('exp', exp.toString()) 64 | 65 | return res.data.access_token 66 | } catch (err) { 67 | throw err 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/utils/subredditParser.ts: -------------------------------------------------------------------------------- 1 | // types of posts are self:text general:link, video:link 2 | // video: hosted or image 3 | 4 | interface PostData { 5 | author: string 6 | created_utc: number 7 | domain: string 8 | link_flair_text: string 9 | num_comments: number 10 | preview: { 11 | images: { 12 | source: { 13 | url: string 14 | } 15 | }[] 16 | reddit_video_preview: any 17 | } 18 | post_hint?: string 19 | score: number 20 | selftext: string 21 | secure_media?: any 22 | subreddit: string 23 | stickied: boolean 24 | thumbnail: string 25 | title: string 26 | url: string 27 | } 28 | 29 | export const getPostType = (postData: PostData): string => { 30 | const { post_hint, preview, selftext, secure_media, url } = postData 31 | 32 | // self post 33 | if (selftext.length > 0) { 34 | return 'self' 35 | // link with no image 36 | } else if (!preview && selftext.length === 0) { 37 | return 'link' 38 | // link with an image 39 | } else if (post_hint === 'link' && !preview.reddit_video_preview) { 40 | return 'link:preview' 41 | // gifycat,imgur, or redgif link 42 | } else if (preview.reddit_video_preview) { 43 | return 'video:outside' 44 | // reddit hosted video 45 | } else if (secure_media) { 46 | if (post_hint === 'rich:video') { 47 | return 'link:video' 48 | } else { 49 | return 'video:hosted' 50 | } 51 | } else if (url.split('.').slice(-1)[0] === 'gif') { 52 | return 'video:native' 53 | } else return 'image' 54 | } 55 | 56 | export const getMedia = (postData: PostData, type: string) => { 57 | if (type === 'self' || type === 'link') { 58 | return null 59 | } else if ( 60 | type === 'image' || 61 | type === 'link:video' || 62 | type === 'link:preview' 63 | ) { 64 | return postData.preview.images[0].source.url 65 | } else if (type === 'video:native') { 66 | return postData.url 67 | } else if (type === 'video:hosted') { 68 | return postData.secure_media.reddit_video.fallback_url 69 | } else if (type === 'video:outside') { 70 | return postData.preview.reddit_video_preview.fallback_url 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/variants.ts: -------------------------------------------------------------------------------- 1 | export const parentVariants = { 2 | hidden: { 3 | transition: { 4 | staggerChildren: 0.04 5 | } 6 | }, 7 | visible: { 8 | transition: { 9 | staggerChildren: 0.04 10 | } 11 | } 12 | } 13 | 14 | export const childVariants = { 15 | hidden: { 16 | x: -1000 17 | }, 18 | visible: { 19 | x: 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react" 17 | }, 18 | "include": ["src"] 19 | } 20 | --------------------------------------------------------------------------------