├── src ├── react-app-env.d.ts ├── components │ ├── Header │ │ ├── img │ │ │ ├── moon.png │ │ │ └── sun.png │ │ └── index.tsx │ ├── Modals │ │ ├── index.tsx │ │ ├── AuthLoadingModal │ │ │ └── index.tsx │ │ ├── container.tsx │ │ └── AuthErrorModal │ │ │ └── index.tsx │ ├── LogoutButton │ │ └── index.tsx │ ├── ErrorBoundary │ │ └── index.tsx │ ├── common │ │ ├── CustomParticles │ │ │ └── index.tsx │ │ └── PostCard │ │ │ └── index.tsx │ ├── Footer │ │ └── index.tsx │ └── JobDetails │ │ └── index.tsx ├── setupTests.ts ├── app │ ├── store.ts │ ├── modular │ │ ├── auth │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── actions.ts │ │ ├── post │ │ │ ├── selectors.ts │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── reducer.ts │ │ │ └── actions.ts │ │ └── application │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ ├── selectors.ts │ │ │ ├── reducer.ts │ │ │ └── actions.ts │ ├── types.ts │ └── reducer.ts ├── index.css ├── client │ ├── user.ts │ ├── jwt.ts │ └── index.ts ├── utils │ ├── window-size.ts │ └── relative-time.ts ├── App.css ├── logo.svg ├── index.tsx ├── theme.ts ├── App.tsx ├── pages │ ├── Landing │ │ └── index.tsx │ ├── Login │ │ └── index.tsx │ └── Signup │ │ └── index.tsx └── serviceWorker.ts ├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── .travis.yml ├── .eslintrc.js ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardxhong/talent-frontend/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardxhong/talent-frontend/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardxhong/talent-frontend/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/components/Header/img/moon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardxhong/talent-frontend/HEAD/src/components/Header/img/moon.png -------------------------------------------------------------------------------- /src/components/Header/img/sun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/richardxhong/talent-frontend/HEAD/src/components/Header/img/sun.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | branches: 5 | only: 6 | - master 7 | script: 8 | - "yarn run lint" 9 | - "yarn run types" -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['airbnb-typescript'], 3 | parserOptions: { 4 | project: './tsconfig.json', 5 | }, 6 | rules: { 7 | "no-param-reassign": 8 | ["error", { "props": false }] 9 | } 10 | }; -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/app/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Store, 3 | createStore, 4 | applyMiddleware, 5 | } from 'redux'; 6 | import thunk from 'redux-thunk'; 7 | import reducer from './reducer'; 8 | 9 | const store: Store = createStore( 10 | reducer, 11 | applyMiddleware(thunk), 12 | ); 13 | 14 | export default store; 15 | -------------------------------------------------------------------------------- /src/app/modular/auth/types.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | email: string 3 | executive: boolean 4 | firstName: string 5 | lastName: string 6 | updatedAt: Date 7 | } 8 | 9 | export interface AuthState { 10 | loading: boolean, 11 | loggedIn: boolean, 12 | currentUser?: User 13 | error?: string 14 | } 15 | -------------------------------------------------------------------------------- /src/app/types.ts: -------------------------------------------------------------------------------- 1 | export type RootState = { 2 | [duckName: string]: object; 3 | }; 4 | 5 | export interface ActionCreator { 6 | (params: Payload): { 7 | type: string; 8 | payload: Payload; 9 | }; 10 | actionType: string; 11 | } 12 | 13 | export type Selector = ( 14 | state: RootState, 15 | ownProp: OwnPropType 16 | ) => RT; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import authDuck from './modular/auth'; 4 | import postDuck from './modular/post'; 5 | import applicationsDuck from './modular/application'; 6 | 7 | const reducer = combineReducers({ 8 | [authDuck.name]: authDuck.reducer, 9 | [postDuck.name]: postDuck.reducer, 10 | [applicationsDuck.name]: applicationsDuck.reducer, 11 | }); 12 | 13 | export default reducer; 14 | -------------------------------------------------------------------------------- /src/app/modular/post/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import { RootState } from '../../types'; 3 | import { PostState, Post } from './types'; 4 | 5 | const select = (state: RootState): PostState => state.post as PostState; 6 | 7 | export const posts = (state: RootState): Post[] => Object.values(select(state).posts); 8 | 9 | export const post = (state: RootState, id: number): Post | undefined => select(state).posts[id]; 10 | -------------------------------------------------------------------------------- /src/app/modular/auth/index.ts: -------------------------------------------------------------------------------- 1 | import reducer, { AuthReducer } from './reducer'; 2 | import * as actions from './actions'; 3 | import * as selectors from './selectors'; 4 | 5 | interface AuthDuck { 6 | name: string 7 | actions: typeof actions 8 | selectors: typeof selectors 9 | reducer: AuthReducer 10 | } 11 | 12 | const duck: AuthDuck = { 13 | name: 'auth', 14 | actions, 15 | reducer, 16 | selectors, 17 | }; 18 | 19 | export default duck; 20 | -------------------------------------------------------------------------------- /src/app/modular/post/index.ts: -------------------------------------------------------------------------------- 1 | import reducer, { PostReducer } from './reducer'; 2 | import * as actions from './actions'; 3 | import * as selectors from './selectors'; 4 | 5 | interface PostDuck { 6 | name: string 7 | actions: typeof actions 8 | selectors: typeof selectors 9 | reducer: PostReducer 10 | } 11 | 12 | const duck: PostDuck = { 13 | name: 'post', 14 | actions, 15 | reducer, 16 | selectors, 17 | }; 18 | 19 | export default duck; 20 | -------------------------------------------------------------------------------- /src/app/modular/application/index.ts: -------------------------------------------------------------------------------- 1 | import reducer, { ApplicationReducer } from './reducer'; 2 | import * as actions from './actions'; 3 | import * as selectors from './selectors'; 4 | 5 | interface ApplicationDuck { 6 | name: string, 7 | actions: typeof actions, 8 | selectors: typeof selectors, 9 | reducer: ApplicationReducer, 10 | } 11 | 12 | const duck: ApplicationDuck = { 13 | name: 'application', 14 | actions, 15 | selectors, 16 | reducer, 17 | }; 18 | 19 | export default duck; 20 | -------------------------------------------------------------------------------- /src/app/modular/post/types.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../auth/types'; 2 | 3 | export interface Post { 4 | id: number 5 | author: User 6 | active: boolean 7 | title: string 8 | description: string 9 | desirements: string[] 10 | requirements: string[] 11 | createdAt: Date 12 | expiresAt: Date 13 | updatedAt: Date 14 | } 15 | 16 | export interface Posts { 17 | [id: number]: Post 18 | } 19 | 20 | export interface PostState { 21 | loading: boolean 22 | posts: Posts 23 | error?: string 24 | } 25 | -------------------------------------------------------------------------------- /src/app/modular/auth/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import { RootState } from '../../types'; 3 | 4 | import type { AuthState } from './types'; 5 | 6 | const select = (state: RootState): AuthState => state.auth as AuthState; 7 | 8 | export const loading = (state: RootState): boolean => select(state).loading; 9 | 10 | export const loggedIn = (state: RootState): boolean => select(state).loggedIn; 11 | 12 | export const error = (state: RootState): string | undefined => select(state).error; 13 | -------------------------------------------------------------------------------- /src/app/modular/application/types.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../auth/types'; 2 | 3 | export interface Application { 4 | applicant: User, 5 | id: number, 6 | postId: number, 7 | status: number, 8 | appliedAt: Date, 9 | gitHubURL?: string, 10 | linkedInURL?: string, 11 | portfolioURL?: string, 12 | otherURL?: string, 13 | resumeURL?: string, 14 | additionalInfo?: string, 15 | } 16 | 17 | export interface Applications { 18 | [id: number]: Application 19 | } 20 | 21 | export interface ApplicationState { 22 | applications: Applications, 23 | } 24 | -------------------------------------------------------------------------------- /src/client/user.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../app/modular/auth/types'; 2 | 3 | const CURRENT_USER = 'user:current'; 4 | 5 | export const getCurrentUser = (): User | null => { 6 | const userString = localStorage.getItem(CURRENT_USER); 7 | if (!userString) { 8 | return null; 9 | } 10 | return JSON.parse(userString); 11 | }; 12 | 13 | export const saveCurrentUser = (user: User) => { 14 | localStorage.setItem(CURRENT_USER, JSON.stringify(user)); 15 | }; 16 | 17 | export const clearCurrentUser = () => { 18 | localStorage.removeItem(CURRENT_USER); 19 | }; 20 | -------------------------------------------------------------------------------- /src/app/modular/application/selectors.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/prefer-default-export */ 2 | import { RootState } from '../../types'; 3 | import { ApplicationState, Application } from './types'; 4 | 5 | const select = (state: RootState): ApplicationState => state.application as ApplicationState; 6 | 7 | export const applications = (state: RootState): Application[] => ( 8 | Object.values(select(state).applications) 9 | ); 10 | 11 | export const application = (state: RootState, id: number): Application | undefined => ( 12 | select(state).applications[id] 13 | ); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/utils/window-size.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | function getWindowDimensions() { 4 | const { innerWidth: width, innerHeight: height } = window; 5 | return { 6 | width, 7 | height, 8 | }; 9 | } 10 | 11 | export default function useWindowDimensions() { 12 | const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); 13 | 14 | useEffect(() => { 15 | function handleResize() { 16 | setWindowDimensions(getWindowDimensions()); 17 | } 18 | 19 | window.addEventListener('resize', handleResize); 20 | return () => window.removeEventListener('resize', handleResize); 21 | }, []); 22 | 23 | return windowDimensions; 24 | } 25 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-float infinite 3s ease-in-out; 13 | } 14 | } 15 | 16 | .App-header { 17 | min-height: 100vh; 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | font-size: calc(10px + 2vmin); 23 | } 24 | 25 | .App-link { 26 | color: rgb(112, 76, 182); 27 | } 28 | 29 | @keyframes App-logo-float { 30 | 0% { 31 | transform: translateY(0); 32 | } 33 | 50% { 34 | transform: translateY(10px) 35 | } 36 | 100% { 37 | transform: translateY(0px) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Modals/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import AuthLoadingModal from './AuthLoadingModal'; 4 | import { RootState } from '../../app/types'; 5 | import authDuck from '../../app/modular/auth'; 6 | import AuthErrorModal from './AuthErrorModal'; 7 | 8 | interface ConnectedProps { 9 | authLoadingModalVisible: boolean, 10 | error?: string 11 | } 12 | 13 | const Modals: React.FC = ({ 14 | authLoadingModalVisible, error, 15 | }: ConnectedProps) => ( 16 | <> 17 | {(authLoadingModalVisible && ) || (error && )} 18 | 19 | ); 20 | 21 | const mapStateToProps = (state: RootState): ConnectedProps => ({ 22 | authLoadingModalVisible: authDuck.selectors.loading(state), 23 | error: authDuck.selectors.error(state), 24 | }); 25 | 26 | export default connect(mapStateToProps)(Modals); 27 | -------------------------------------------------------------------------------- /src/app/modular/application/reducer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import produce from 'immer'; 3 | import type { Reducer } from 'redux'; 4 | import { ActionType } from 'typesafe-actions'; 5 | 6 | import type { ApplicationState } from './types'; 7 | import * as actions from './actions'; 8 | 9 | type ApplicationAction = ActionType; 10 | 11 | const initialState: ApplicationState = { 12 | applications: {}, 13 | }; 14 | 15 | export type ApplicationReducer = Reducer; 16 | 17 | const reducer: ApplicationReducer = produce( 18 | (state: ApplicationState, action: ApplicationAction) => { 19 | switch (action.type) { 20 | case actions.SAVE_APPLICATIONS: 21 | state.applications = action.payload.applications; 22 | break; 23 | default: 24 | break; 25 | } 26 | }, 27 | initialState, 28 | ); 29 | 30 | export default reducer; 31 | -------------------------------------------------------------------------------- /src/client/jwt.ts: -------------------------------------------------------------------------------- 1 | import { clearCurrentUser } from './user'; 2 | 3 | export const JWT_ACCESS_KEY = 'jwt:access'; 4 | const JWT_EXPIRY = 'jwt:expiry'; 5 | 6 | export const JWT_ACCESS_EXPIRY = 1000 * 60 * 50; 7 | 8 | export const saveJWT = (access: string): void => { 9 | localStorage.setItem(JWT_ACCESS_KEY, access); 10 | localStorage.setItem(JWT_EXPIRY, String(Date.now() + JWT_ACCESS_EXPIRY)); 11 | }; 12 | 13 | export const clearJWT = (): void => { 14 | localStorage.removeItem(JWT_ACCESS_KEY); 15 | }; 16 | 17 | export const getEncodedAccessToken = (): string | null => { 18 | const expiry = localStorage.getItem(JWT_EXPIRY) as unknown as number; 19 | if (!expiry || Date.now() > expiry) { 20 | clearCurrentUser(); 21 | clearJWT(); 22 | return null; 23 | } 24 | const token = localStorage.getItem(JWT_ACCESS_KEY); 25 | return token ?? null; 26 | }; 27 | 28 | export const isLoggedIn = (): boolean => !!(getEncodedAccessToken()); 29 | -------------------------------------------------------------------------------- /src/components/LogoutButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { compose } from 'redux'; 3 | import { connect } from 'react-redux'; 4 | import { Button } from '@material-ui/core'; 5 | import { useHistory } from 'react-router-dom'; 6 | 7 | import authDuck from '../../app/modular/auth'; 8 | 9 | interface ConnectedActions { 10 | logout: typeof authDuck.actions.logout; 11 | } 12 | 13 | type Props = ConnectedActions; 14 | 15 | const LogoutButton: React.FC = ({ logout }: Props) => { 16 | const history = useHistory(); 17 | 18 | const handleClick = useCallback((): void => { 19 | history.push('/'); 20 | logout(); 21 | }, [history]); 22 | 23 | return ( 24 | 25 | ); 26 | }; 27 | 28 | const mapPropsToDispatch = { 29 | logout: authDuck.actions.logout, 30 | }; 31 | 32 | export default compose( 33 | connect(null, mapPropsToDispatch), 34 | )(LogoutButton); 35 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/ErrorBoundary/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ErrorInfo, ReactNode } from 'react'; 2 | 3 | interface ErrorState { 4 | error: Error | null 5 | info: ErrorInfo | null 6 | } 7 | 8 | type State = ErrorState; 9 | 10 | type Props = { children: ReactNode }; 11 | 12 | class ErrorBoundary extends React.PureComponent { 13 | constructor(props: Props) { 14 | super(props); 15 | this.state = { error: null, info: null }; 16 | } 17 | 18 | componentDidCatch = (error: Error, info: ErrorInfo) => { 19 | this.setState({ 20 | error, 21 | info, 22 | }); 23 | }; 24 | 25 | render() { 26 | const { info, error } = this.state; 27 | const { children } = this.props; 28 | return ( 29 | info ? ( 30 |
31 |

Something went seriouosly wrong.

32 |
33 | {error && error.toString()} 34 |
35 | {info!.componentStack} 36 |
37 |
38 | ) : (children) 39 | ); 40 | } 41 | } 42 | 43 | export default ErrorBoundary; 44 | -------------------------------------------------------------------------------- /src/components/common/CustomParticles/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Particles from 'react-particles-js'; 3 | import { useThemeMode, computeTheme } from '../../../theme'; 4 | import useWindowDimensions from '../../../utils/window-size'; 5 | 6 | function CustomParticles() { 7 | const currentTheme = useThemeMode(); 8 | const theme = computeTheme(currentTheme); 9 | 10 | const { height } = useWindowDimensions(); 11 | let numParticles = 120; 12 | if (height < 800) { 13 | numParticles = 20; 14 | } 15 | 16 | return ( 17 | 35 | ); 36 | } 37 | 38 | export default CustomParticles; 39 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import { Provider } from 'react-redux'; 5 | import { BrowserRouter as Router } from 'react-router-dom'; 6 | 7 | import App from './App'; 8 | import store from './app/store'; 9 | import * as serviceWorker from './serviceWorker'; 10 | import { isLoggedIn } from './client/jwt'; 11 | import { loginSuccess } from './app/modular/auth/actions'; 12 | import { getCurrentUser } from './client/user'; 13 | 14 | const user = getCurrentUser(); 15 | if (isLoggedIn() && user) { 16 | user.updatedAt = new Date(user.updatedAt); 17 | store.dispatch(loginSuccess( 18 | user, 19 | )); 20 | } 21 | 22 | ReactDOM.render( 23 | 24 | 25 | 26 | 27 | 28 | 29 | , 30 | document.getElementById('root'), 31 | ); 32 | 33 | // If you want your app to work offline and load faster, you can change 34 | // unregister() to register() below. Note this comes with some pitfalls. 35 | // Learn more about service workers: https://bit.ly/CRA-PWA 36 | serviceWorker.unregister(); 37 | -------------------------------------------------------------------------------- /src/app/modular/application/actions.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | import { action } from 'typesafe-actions'; 3 | import { ThunkAction } from 'redux-thunk'; 4 | import { RootState } from '../../types'; 5 | import { Applications } from './types'; 6 | import * as request from '../../../client'; 7 | 8 | export const SAVE_APPLICATIONS = 'applications/saveApplications'; 9 | 10 | export const saveApplications = (applications: Applications) => ( 11 | action(SAVE_APPLICATIONS, { applications }) 12 | ); 13 | 14 | export const getApplications = (): 15 | ThunkAction => (async (dispatch) => { 16 | try { 17 | const res = await request.get({ 18 | url: 'applications', 19 | }); 20 | const applications: Applications = {}; 21 | for (const application of res) { 22 | application.applicant.updatedAt = new Date(application.applicant.updatedAt); 23 | application.appliedAt = new Date(application.appliedAt); 24 | applications[application.id] = application; 25 | } 26 | dispatch(saveApplications(applications)); 27 | } catch (err) { 28 | // eslint-disable-next-line no-console 29 | console.error(err); 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography, Link } from '@material-ui/core'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | function FooterInfo() { 6 | return ( 7 | 8 | 9 | Talent 10 | 11 | {' '} 12 | {new Date().getFullYear()} 13 | . 14 | 15 | ); 16 | } 17 | 18 | const useStyles = makeStyles((theme) => ({ 19 | footer: { 20 | padding: theme.spacing(2, 3), 21 | position: 'fixed', 22 | bottom: '0', 23 | left: '0', 24 | width: '100%', 25 | backgroundColor: 26 | theme.palette.type === 'light' 27 | ? theme.palette.grey[200] 28 | : theme.palette.grey[800], 29 | }, 30 | })); 31 | 32 | export default function Footer() { 33 | const classes = useStyles(); 34 | 35 | return ( 36 |
37 | 38 | Built with React, Redux, TypeScript and 39 | {' '} 40 | 41 | ❤️ 42 | 43 | 44 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/Modals/AuthLoadingModal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography, LinearProgress } from '@material-ui/core'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | import { connect } from 'react-redux'; 6 | import Container from '../container'; 7 | import { RootState } from '../../../app/types'; 8 | 9 | import authDuck from '../../../app/modular/auth'; 10 | 11 | interface ConnectedProps { 12 | loading: boolean; 13 | } 14 | 15 | type Props = ConnectedProps; 16 | 17 | const useStyles = makeStyles((theme) => ({ 18 | paper: { 19 | outline: 'none', 20 | }, 21 | linearProgress: { 22 | width: '100%', 23 | marginTop: theme.spacing(1), 24 | }, 25 | loading: { 26 | marginTop: theme.spacing(1), 27 | }, 28 | })); 29 | 30 | const AuthLoadingModal: React.FC = ({ 31 | loading, 32 | }: Props) => { 33 | const classes = useStyles(); 34 | return ( 35 | 39 | 40 | 41 | Loading... 42 | 43 | 44 | ); 45 | }; 46 | 47 | const mapStateToProps = (state: RootState): Props => ({ 48 | loading: authDuck.selectors.loading(state), 49 | }); 50 | 51 | export default connect(mapStateToProps)(AuthLoadingModal); 52 | -------------------------------------------------------------------------------- /src/components/common/PostCard/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CssBaseline, 3 | Typography, 4 | Button, 5 | Grid, 6 | } from '@material-ui/core'; 7 | import React from 'react'; 8 | import { useHistory } from 'react-router-dom'; 9 | import { Post } from '../../../app/modular/post/types'; 10 | 11 | interface Props { 12 | post: Post 13 | } 14 | 15 | function PostCard(props: Props) { 16 | const { post } = props; 17 | const { title, author } = post; 18 | const history = useHistory(); 19 | 20 | const handleClick = () => { 21 | history.push(`/post/${post.id}`); 22 | }; 23 | 24 | return ( 25 | 26 | 27 | 28 | 29 | {title} 30 | 31 | {`${author.firstName} ${author.lastName}`} 32 | 33 | 34 | 35 | 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | 45 | export default PostCard; 46 | -------------------------------------------------------------------------------- /src/utils/relative-time.ts: -------------------------------------------------------------------------------- 1 | function calculateRelativeTime(prevTime: Date): string { 2 | const msPerMinute = 60 * 1000; 3 | const msPerHour = msPerMinute * 60; 4 | const msPerDay = msPerHour * 24; 5 | const msPerMonth = msPerDay * 30; 6 | const msPerYear = msPerDay * 365; 7 | 8 | const difference = new Date().getTime() - prevTime.getTime(); 9 | 10 | let relativeTime; 11 | if (difference < msPerMinute) { 12 | relativeTime = `${Math.round(difference / 1000)} seconds ago`; 13 | } else if (difference < msPerHour) { 14 | relativeTime = `${Math.round(difference / msPerMinute)} minutes ago`; 15 | } else if (difference < msPerDay) { 16 | relativeTime = `${Math.round(difference / msPerHour)} hours ago`; 17 | } else if (difference < msPerMonth) { 18 | relativeTime = `${Math.round(difference / msPerDay)} days ago`; 19 | } else if (difference < msPerYear) { 20 | relativeTime = `${Math.round(difference / msPerMonth)} months ago`; 21 | } else { 22 | relativeTime = `${Math.round(difference / msPerYear)} years ago`; 23 | } 24 | 25 | // using slicing to remove the plural if the number is 1 26 | if (relativeTime.charAt(0) === '1' && relativeTime.charAt(1) === ' ') { 27 | relativeTime = relativeTime.slice(0, relativeTime.length - 5) 28 | + relativeTime.slice(-4, relativeTime.length); 29 | } 30 | 31 | return relativeTime; 32 | } 33 | 34 | export default calculateRelativeTime; 35 | -------------------------------------------------------------------------------- /src/components/Modals/container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | makeStyles, Modal, Backdrop, Fade, Paper, 4 | } from '@material-ui/core'; 5 | 6 | export const useStyles = makeStyles((theme) => ({ 7 | modal: { 8 | display: 'flex', 9 | alignItems: 'center', 10 | justifyContent: 'center', 11 | }, 12 | paper: { 13 | backgroundColor: theme.palette.background.paper, 14 | boxShadow: theme.shadows[8], 15 | padding: theme.spacing(2, 4, 3), 16 | }, 17 | })); 18 | 19 | interface Props { 20 | handleClose?: VoidFunction; 21 | children: React.ReactNode; 22 | open: boolean; 23 | className?: string; 24 | } 25 | 26 | const Container: React.FC = ({ 27 | handleClose, 28 | children, 29 | open, 30 | className, 31 | }: Props) => { 32 | const classes = useStyles(); 33 | 34 | return ( 35 | 47 | 50 | 51 | {children} 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default Container; 59 | -------------------------------------------------------------------------------- /src/app/modular/post/reducer.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import produce from 'immer'; 3 | import type { Reducer } from 'redux'; 4 | import { ActionType } from 'typesafe-actions'; 5 | 6 | import type { PostState } from './types'; 7 | import * as actions from './actions'; 8 | 9 | type PostAction = ActionType; 10 | 11 | const initialState: PostState = { 12 | loading: false, 13 | posts: {}, 14 | }; 15 | 16 | export type PostReducer = Reducer; 17 | 18 | const reducer: PostReducer = produce( 19 | (state: PostState, action: PostAction) => { 20 | switch (action.type) { 21 | case actions.LOADING_START: 22 | state.loading = true; 23 | break; 24 | case actions.SAVE_POSTS: 25 | state.posts = action.payload.posts; 26 | break; 27 | case actions.ADD_POST: 28 | // eslint-disable-next-line no-case-declarations 29 | const { post } = action.payload; 30 | state.posts[post.id] = post; 31 | state.loading = false; 32 | break; 33 | case actions.LOADING_SUCCESS: 34 | state.loading = false; 35 | break; 36 | case actions.LOADING_ERROR: 37 | state.loading = false; 38 | switch (action.payload.error.slice(-3)) { 39 | case '401': 40 | state.error = 'Unauthorized'; 41 | break; 42 | default: 43 | state.error = action.payload.error; 44 | break; 45 | } 46 | break; 47 | case actions.CLEAR_ERROR: 48 | state.error = undefined; 49 | break; 50 | default: 51 | break; 52 | } 53 | }, 54 | initialState, 55 | ); 56 | 57 | export default reducer; 58 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { createMuiTheme, Theme } from '@material-ui/core'; 3 | 4 | export const useThemeMode = () => { 5 | const localTheme = localStorage.getItem('themeType'); 6 | const [currentTheme, setCurrentTheme] = useState<'light' | 'dark' | undefined>( 7 | localTheme === 'light' || localTheme === 'dark' ? localTheme : 'light', 8 | ); 9 | 10 | const setTheme = () => { 11 | const theme = localStorage.getItem('themeType'); 12 | if (theme === 'light' || theme === 'dark') { 13 | setCurrentTheme(theme ?? undefined); 14 | } 15 | }; 16 | 17 | useEffect(() => { 18 | window.addEventListener('storage themeType', () => { 19 | setTheme(); 20 | }, false); 21 | if (!localStorage.getItem('themeType')) { 22 | localStorage.setItem('themeType', 'light'); 23 | } 24 | }); 25 | 26 | const { setItem } = localStorage; 27 | // eslint-disable-next-line func-names 28 | localStorage.setItem = function (key, value) { 29 | setItem.apply(this, [key, value]); 30 | const e = new Event(`storage ${key}`); 31 | window.dispatchEvent(e); 32 | }; 33 | 34 | return currentTheme; 35 | }; 36 | 37 | export const computeTheme = (currentTheme: 'light' | 'dark' | undefined): Theme => { 38 | const light = currentTheme === 'light'; 39 | return createMuiTheme({ 40 | palette: { 41 | type: currentTheme, 42 | primary: { 43 | main: light ? '#1976d2' : '#f50057', 44 | }, 45 | background: { 46 | default: light ? '#fafafa' : '#121212', 47 | paper: light ? '#fff' : '#1c1c1c', 48 | }, 49 | error: { 50 | main: light ? '#B00020' : '#CF6679', 51 | contrastText: light ? '#FFFFFF' : '#121212', 52 | }, 53 | }, 54 | }); 55 | }; 56 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useEffect } from 'react'; 2 | import { Route, Switch, Redirect } from 'react-router-dom'; 3 | import { ThemeProvider, Theme } from '@material-ui/core'; 4 | import './App.css'; 5 | import { useDispatch } from 'react-redux'; 6 | import Login from './pages/Login'; 7 | import Signup from './pages/Signup'; 8 | import Landing from './pages/Landing'; 9 | import { isLoggedIn } from './client/jwt'; 10 | import Modals from './components/Modals'; 11 | import Header from './components/Header'; 12 | import Footer from './components/Footer'; 13 | import { useThemeMode, computeTheme } from './theme'; 14 | import JobDetails from './components/JobDetails'; 15 | import { getPosts } from './app/modular/post/actions'; 16 | 17 | function App() { 18 | const dispatch = useDispatch(); 19 | useEffect(() => { 20 | dispatch(getPosts()); 21 | }); 22 | const currentTheme = useThemeMode(); 23 | 24 | const theme: Theme = useMemo( 25 | () => computeTheme(currentTheme), 26 | [currentTheme], 27 | ); 28 | 29 | return ( 30 | 31 |
32 | 33 | (isLoggedIn() ? : )} /> 34 | (isLoggedIn() ? : )} /> 35 | ()} /> 36 | } 46 | /> 47 | 48 | 49 |