├── public
├── robots.txt
├── favicon.ico
├── logo192.png
├── logo512.png
├── manifest.json
├── index.html
└── primitiveui.css
├── prettier.config.js
├── src
├── setupTests.js
├── features
│ ├── posts
│ │ ├── PostAuthor.js
│ │ ├── TimeAgo.js
│ │ ├── ReactionButton.js
│ │ ├── SinglePost.js
│ │ ├── UpdatePostForm.js
│ │ ├── PostsList.js
│ │ ├── AddPostForm.js
│ │ └── postsSlice.js
│ ├── users
│ │ ├── UsersList.js
│ │ ├── usersSlice.js
│ │ └── UserPage.js
│ └── notifications
│ │ ├── notificationsSlice.js
│ │ └── NotificationsList.js
├── app
│ ├── store.js
│ └── Navbar.js
├── index.js
├── api
│ ├── client.js
│ └── server.js
├── App.js
└── index.css
├── .gitignore
├── package.json
└── README.md
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "semi": false,
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Huniko519/SocialApp-React-Redux/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Huniko519/SocialApp-React-Redux/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Huniko519/SocialApp-React-Redux/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/src/features/posts/PostAuthor.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useSelector } from 'react-redux'
3 |
4 | export const PostAuthor = ({ userId }) => {
5 | const author = useSelector(state =>
6 | state.users.find(user => user.id === userId)
7 | )
8 |
9 | return by {author ? author.name : 'Unknown author'}
10 | }
--------------------------------------------------------------------------------
/.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/app/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit'
2 |
3 | import postsReducer from '../features/posts/postsSlice'
4 | import usersReducer from '../features/users/usersSlice'
5 | import notificationsReducer from '../features/notifications/notificationsSlice'
6 | export default configureStore({
7 | reducer: {
8 | posts: postsReducer,
9 | users: usersReducer,
10 | notifications: notificationsReducer
11 | }
12 | })
--------------------------------------------------------------------------------
/src/features/posts/TimeAgo.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { parseISO, formatDistanceToNow } from 'date-fns'
3 |
4 | export const TimeAgo = ({ timestamp }) => {
5 | let timeAgo = ''
6 | if (timestamp) {
7 | const date = parseISO(timestamp)
8 | const timePeriod = formatDistanceToNow(date)
9 | timeAgo = `${timePeriod} ago`
10 | }
11 |
12 | return (
13 |
14 | {timeAgo}
15 |
16 | )
17 | }
--------------------------------------------------------------------------------
/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/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import './index.css'
4 | import App from './App'
5 | import store from './app/store'
6 | import { fetchUsers } from './features/users/usersSlice'
7 | import { fetchPosts } from './features/posts/postsSlice'
8 | import { Provider } from 'react-redux'
9 |
10 | import './api/server'
11 |
12 | store.dispatch(fetchUsers())
13 | store.dispatch(fetchPosts())
14 |
15 | ReactDOM.render(
16 |
17 |
18 |
19 |
20 | ,
21 | document.getElementById('root')
22 | )
23 |
--------------------------------------------------------------------------------
/src/features/users/UsersList.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useSelector } from 'react-redux'
3 | import { Link } from 'react-router-dom'
4 | import { selectAllUsers } from './usersSlice'
5 |
6 | export const UsersList = () => {
7 | const users = useSelector(selectAllUsers)
8 |
9 | const renderedUsers = users.map(user => (
10 |
11 | {user.name}
12 |
13 | ))
14 | return (
15 |
16 | Users
17 |
18 | {renderedUsers}
19 |
20 |
21 | )
22 | }
--------------------------------------------------------------------------------
/src/features/posts/ReactionButton.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useDispatch } from 'react-redux'
3 |
4 | import { reactionAdded } from './postsSlice'
5 |
6 | const reactionEmoji = {
7 | thumbsUp: '👍',
8 | hooray: '🎉',
9 | }
10 |
11 | export const ReactionButtons = ({ post }) => {
12 | const dispatch = useDispatch()
13 |
14 | const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
15 | return (
16 |
21 | dispatch(reactionAdded({ postId: post.id, reaction: name }))
22 | }
23 | >
24 | {emoji} {post.reactions[name]}
25 |
26 | )
27 | })
28 |
29 | return {reactionButtons}
30 | }
--------------------------------------------------------------------------------
/src/features/posts/SinglePost.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useSelector } from 'react-redux'
3 | import { Link } from 'react-router-dom'
4 | import { PostAuthor } from './PostAuthor'
5 | import { TimeAgo } from './TimeAgo'
6 | import { selectPostById } from './postsSlice'
7 |
8 | export const SinglePostPage = ({ match }) => {
9 | const { postID } = match.params
10 | const post = useSelector(state => selectPostById(state, postID))
11 |
12 | if (!post) {
13 | return (
14 |
17 | )
18 | }
19 | return (
20 |
21 |
22 | {post.title}
23 | {post.content}
24 |
25 |
26 | Edit
27 |
28 |
29 | )
30 | }
--------------------------------------------------------------------------------
/src/features/users/usersSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice, createAsyncThunk, createSelector } from '@reduxjs/toolkit'
2 | import { client } from '../../api/client'
3 | import { selectAllPosts } from '../posts/postsSlice'
4 |
5 | const initialState = []
6 |
7 | export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
8 | const response = await client.get('/fakeApi/users')
9 | return response.users
10 | })
11 |
12 | const usersSlice = createSlice({
13 | name: 'users',
14 | initialState,
15 | reducers: {},
16 | extraReducers: {
17 | [fetchUsers.fulfilled]: (state, action) => {
18 | return action.payload
19 | }
20 | }
21 | })
22 | export const selectAllUsers = state => state.users;
23 | export const selectUserById = (state, userId) =>
24 | state.users.find( user => user.id === userId)
25 | export const selectPostByUser = createSelector(
26 | [selectAllPosts, (state, userId) => userId],
27 | (posts, userId) => posts.filter(post => post.user === userId)
28 | )
29 |
30 | export default usersSlice.reducer
--------------------------------------------------------------------------------
/src/api/client.js:
--------------------------------------------------------------------------------
1 | // A tiny wrapper around fetch(), borrowed from
2 | // https://kentcdodds.com/blog/replace-axios-with-a-simple-custom-fetch-wrapper
3 |
4 | export async function client(endpoint, { body, ...customConfig } = {}) {
5 | const headers = { 'Content-Type': 'application/json' }
6 |
7 | const config = {
8 | method: body ? 'POST' : 'GET',
9 | ...customConfig,
10 | headers: {
11 | ...headers,
12 | ...customConfig.headers,
13 | },
14 | }
15 |
16 | if (body) {
17 | config.body = JSON.stringify(body)
18 | }
19 |
20 | let data
21 | try {
22 | const response = await window.fetch(endpoint, config)
23 | data = await response.json()
24 | if (response.ok) {
25 | return data
26 | }
27 | throw new Error(response.statusText)
28 | } catch (err) {
29 | return Promise.reject(err.message ? err.message : data)
30 | }
31 | }
32 |
33 | client.get = function (endpoint, customConfig = {}) {
34 | return client(endpoint, { ...customConfig, method: 'GET' })
35 | }
36 |
37 | client.post = function (endpoint, body, customConfig = {}) {
38 | return client(endpoint, { ...customConfig, body })
39 | }
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-Social-app",
3 | "version": "1.0.0",
4 | "private": true,
5 | "dependencies": {
6 | "@reduxjs/toolkit": "^1.4.0",
7 | "@testing-library/jest-dom": "^4.2.4",
8 | "@testing-library/react": "^9.3.2",
9 | "@testing-library/user-event": "^7.1.2",
10 | "classnames": "^2.2.6",
11 | "date-fns": "^2.12.0",
12 | "faker": "^4.1.0",
13 | "miragejs": "^0.1.35",
14 | "react": "^16.13.1",
15 | "react-dom": "^16.13.1",
16 | "react-redux": "^7.2.0",
17 | "react-router-dom": "^5.1.2",
18 | "react-scripts": "3.4.1",
19 | "redux-devtools-extension": "^2.13.8",
20 | "seedrandom": "^3.0.5",
21 | "txtgen": "^2.2.4"
22 | },
23 | "scripts": {
24 | "start": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": "react-app"
31 | },
32 | "browserslist": {
33 | "production": [
34 | ">0.2%",
35 | "not dead",
36 | "not op_mini all"
37 | ],
38 | "development": [
39 | "last 1 chrome version",
40 | "last 1 firefox version",
41 | "last 1 safari version"
42 | ]
43 | },
44 | "devDependencies": {
45 | "prettier": "^2.0.2"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/app/Navbar.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useDispatch, useSelector } from 'react-redux'
3 | import { Link } from 'react-router-dom'
4 |
5 | import { fetchNotifications, allNotificationsRead, selectAllNotifications } from '../features/notifications/notificationsSlice'
6 |
7 | export const Navbar = () => {
8 | const dispatch = useDispatch()
9 | const notifications = useSelector(selectAllNotifications)
10 | const numUnread = notifications.filter( n => !n.read).length
11 |
12 | const fetchNewNotifications = () => {
13 | dispatch(fetchNotifications())
14 | // dispatch(allNotificationsRead())
15 | }
16 |
17 | let unreadNotificationsBadge
18 |
19 | if(numUnread > 0) {
20 | unreadNotificationsBadge = (
21 | {numUnread}
22 | )
23 | }
24 |
25 | return (
26 |
27 |
28 | Redux Essentials Example
29 |
30 |
31 |
32 | Posts
33 | Users
34 | Notifications {unreadNotificationsBadge}
35 |
36 |
37 | Refresh Notifications
38 |
39 |
40 |
41 |
42 | )
43 | }
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | BrowserRouter as Router,
4 | Switch,
5 | Route,
6 | Redirect,
7 | } from 'react-router-dom'
8 |
9 | import { Navbar } from './app/Navbar'
10 | import { PostsList } from './features/posts/PostsList'
11 | import { AddPostForm } from './features/posts/AddPostForm'
12 | import { SinglePostPage } from './features/posts/SinglePost'
13 | import { UpdatePostForm } from './features/posts/UpdatePostForm'
14 | import { UsersList } from './features/users/UsersList'
15 | import { UserPage } from './features/users/UserPage'
16 | import { NotificationsList } from './features/notifications/NotificationsList'
17 | function App() {
18 | return (
19 |
20 |
21 |
22 |
23 | (
27 |
28 |
29 |
30 |
31 | )}
32 | />
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | export default App
46 |
--------------------------------------------------------------------------------
/src/features/notifications/notificationsSlice.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
2 | import { selectAllPosts } from "../posts/postsSlice";
3 | import { client } from "../../api/client";
4 |
5 | export const fetchNotifications = createAsyncThunk(
6 | 'notifications/fetchNotifications',
7 | async (_, { getState }) => {
8 | const allNotifications = selectAllNotifications(getState())
9 | const [latestNotifications] = allNotifications
10 | const latestTimestamp = latestNotifications ? latestNotifications.date : ""
11 | const response = await client.get(
12 | `/fakeApi/notifications?since=${latestTimestamp}`
13 | )
14 | return response.notifications
15 | }
16 | )
17 |
18 | const notificationsSlice = createSlice({
19 | name: 'notifications',
20 | initialState: [],
21 | reducers: {
22 | allNotificationsRead(state, action) {
23 | state.forEach( notification => {
24 | notification.read = true
25 | })
26 | }
27 | },
28 | extraReducers: {
29 | [fetchNotifications.fulfilled]: (state, action) => {
30 | state.forEach( notification => {
31 | notification.isNew = !notification.read
32 | })
33 | state.push(...action.payload)
34 | state.sort((a, b) => b.date.localeCompare(a.date))
35 | }
36 | }
37 | })
38 |
39 | export const { allNotificationsRead } = notificationsSlice.actions
40 | export default notificationsSlice.reducer
41 |
42 | export const selectAllNotifications = state => state.notifications
--------------------------------------------------------------------------------
/src/features/users/UserPage.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useSelector } from 'react-redux'
3 | import { selectAllPosts } from '../posts/postsSlice'
4 | import { Link } from 'react-router-dom'
5 | import { selectUserById, selectPostByUser } from './usersSlice'
6 |
7 | export const UserPage = ({ match }) => {
8 | const { userId } = match.params
9 | const user = useSelector(state => selectUserById(state, userId))
10 |
11 | // Following code cause the UserPage re-render when any action dispatchs even if the posts data has'nt changed
12 | // Because useSelector forces the component to re-render when we return a new refrence value.
13 | // Here, allPosts.filter(...) returns a new refrence array so the UserPage re-render needlessly.
14 | // So we create selectPostByUser function to use memorized selector so that UserPage not re-render.
15 |
16 | // const postForUser = useSelector(state => {
17 | // const allPosts = selectAllPosts(state)
18 | // return allPosts.filter( post => post.user === userId)
19 | // })
20 |
21 | const postForUser = useSelector(state => selectPostByUser(state, userId))
22 |
23 | if(!user) {
24 | return (
25 |
28 | )
29 | }
30 | const postTitles = postForUser.map( post => (
31 |
32 | { post.title }
33 |
34 | ))
35 |
36 | return (
37 |
38 | { user.name }
39 |
42 |
43 | )
44 | }
--------------------------------------------------------------------------------
/src/features/notifications/NotificationsList.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import { useSelector, useDispatch } from 'react-redux'
3 | import { formatDistanceToNow, parseISO } from 'date-fns'
4 | import classnames from 'classnames'
5 |
6 | import { selectAllUsers } from '../users/usersSlice'
7 | import { selectAllNotifications, allNotificationsRead } from './notificationsSlice'
8 |
9 | export const NotificationsList = () => {
10 | const dispatch = useDispatch();
11 | const notifications = useSelector(selectAllNotifications)
12 | const users = useSelector(selectAllUsers)
13 | useEffect(() => {
14 | dispatch(allNotificationsRead())
15 | })
16 | const renderedNotifications = notifications.map(notification => {
17 | const date = parseISO(notification.date)
18 | const timeAgo = formatDistanceToNow(date)
19 | const user = users.find(user => user.id === notification.user) || {
20 | name: 'Unknown User'
21 | }
22 |
23 | const notificationClassname = classnames('notification', {
24 | new: notification.isNew
25 | })
26 | return (
27 |
28 |
29 | {user.name} {notification.message}
30 |
31 |
32 | {timeAgo} ago
33 |
34 |
35 | )
36 | })
37 |
38 | return (
39 |
40 | Notifications
41 | {renderedNotifications}
42 |
43 | )
44 | }
--------------------------------------------------------------------------------
/src/features/posts/UpdatePostForm.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useDispatch, useSelector } from 'react-redux'
3 | import { postUpdated, selectPostById } from './postsSlice'
4 | import { useHistory } from 'react-router-dom'
5 |
6 | export const UpdatePostForm = ({match}) => {
7 | const { postID } = match.params
8 | const dispatch = useDispatch()
9 | const history = useHistory()
10 |
11 | var post = useSelector(state => selectPostById(state, postID))
12 | if(!post) post = { title: null, content:null }
13 | const [title, setTitle] = useState(post.title)
14 | const [content, setContent] = useState(post.content)
15 |
16 |
17 | const titleChanged = e => setTitle(e.target.value)
18 | const contentChanged = e => setContent(e.target.value)
19 | const updateButtonClicked = () => {
20 | if(title && content) {
21 | dispatch(
22 | postUpdated({
23 | id: postID,
24 | title,
25 | content
26 | })
27 | )
28 | }
29 | history.push(`/posts/${postID}`)
30 | }
31 |
32 | if(!post.title) {
33 | return (
34 |
37 | )
38 | }
39 |
40 | return (
41 |
51 | )
52 | }
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
18 |
19 |
28 | React Redux App
29 |
30 |
31 | You need to enable JavaScript to run this app.
32 |
33 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/src/features/posts/PostsList.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import { useSelector, useDispatch } from 'react-redux'
3 | import { Link } from 'react-router-dom';
4 | import { PostAuthor } from './PostAuthor';
5 | import { TimeAgo } from './TimeAgo';
6 | import { ReactionButtons } from './ReactionButton';
7 | import { selectAllPosts, fetchPosts } from './postsSlice';
8 |
9 | export const PostsList = () => {
10 | const dispatch = useDispatch()
11 | const posts = useSelector(selectAllPosts)
12 | const error = useSelector(state => state.posts.error)
13 | const postStatus = useSelector(state => state.posts.status)
14 |
15 | useEffect(() => {
16 | if (postStatus === 'idle') {
17 | dispatch(fetchPosts())
18 | }
19 | }, [postStatus, dispatch])
20 |
21 | if (postStatus === 'loading') {
22 | return (
23 |
26 | )
27 | }
28 | else if (postStatus === 'failed') {
29 | return (
30 |
33 | )
34 | }
35 | const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))
36 | const renderedPosts = orderedPosts.map(post => (
37 |
38 | {post.title}
39 | {post.content.substring(0, 100)}
40 |
41 |
42 |
43 |
44 | View Post
45 |
46 |
47 | ))
48 | return (
49 |
50 | Posts
51 | {renderedPosts}
52 |
53 | )
54 | }
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Redux Social App
2 |
3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) template.
4 |
5 | ## Available Scripts
6 |
7 | In the project directory, you can run:
8 |
9 | ### `yarn start`
10 |
11 | Runs the app in the development mode.
12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
13 |
14 | The page will reload if you make edits.
15 | You will also see any lint errors in the console.
16 |
17 | ### `yarn test`
18 |
19 | Launches the test runner in the interactive watch mode.
20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
21 |
22 | ### `yarn build`
23 |
24 | Builds the app for production to the `build` folder.
25 | It correctly bundles React in production mode and optimizes the build for the best performance.
26 |
27 | The build is minified and the filenames include the hashes.
28 | Your app is ready to be deployed!
29 |
30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
31 |
32 | ### `yarn eject`
33 |
34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
35 |
36 | 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.
37 |
38 | 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.
39 |
40 | 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.
41 |
42 | ## Learn More
43 |
44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
45 |
46 | To learn React, check out the [React documentation](https://reactjs.org/).
47 |
48 | ### Code Splitting
49 |
50 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
51 |
52 | ### Analyzing the Bundle Size
53 |
54 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
55 |
56 | ### Making a Progressive Web App
57 |
58 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
59 |
60 | ### Advanced Configuration
61 |
62 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
63 |
64 | ### Deployment
65 |
66 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
67 |
68 | ### `yarn build` fails to minify
69 |
70 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
71 |
--------------------------------------------------------------------------------
/src/features/posts/AddPostForm.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useDispatch, useSelector } from 'react-redux'
3 |
4 | import { postAdded, addNewPost } from './postsSlice'
5 | import { unwrapResult } from '@reduxjs/toolkit'
6 |
7 | export const AddPostForm = () => {
8 | const [title, setTitle] = useState('')
9 | const [content, setContent] = useState('')
10 | const [userId, setUserId] = useState('')
11 | const [addRequestStatus, setAddRequestStatus] = useState('idle')
12 |
13 | const dispatch = useDispatch()
14 |
15 | const users = useSelector(state => state.users)
16 |
17 | const onTitleChanged = e => setTitle(e.target.value)
18 | const onContentChanged = e => setContent(e.target.value)
19 | const onAuthorChanged = e => setUserId(e.target.value)
20 |
21 | const canSave = [title, content, userId].every(Boolean) && (addRequestStatus === 'idle')
22 |
23 | const onSavePostClicked = async () => {
24 | if (canSave) {
25 | try {
26 | setAddRequestStatus('pending')
27 | const resultAction = await dispatch(
28 | addNewPost({ title, content, user: userId })
29 | )
30 | // WHY TO USE UNWRAPRESULT FUNCTION:
31 | // createAsyncThunk handles any errors internally, so that we DONT SEE any messages
32 | // about "rejected Promises" in our logs.
33 | // It then returns the final action it dispatched:
34 | // either the fulfilled action if it succeeded,
35 | // or the rejected action if it failed.
36 | // Redux Toolkit has a utility function called unwrapResult that will return
37 | // either the ACTUAL action.payload value from a fulfilled action,
38 | // or throw an ERROR if it's the rejected action.
39 | unwrapResult(resultAction)
40 | setTitle('')
41 | setContent('')
42 | setUserId('')
43 | }
44 | catch (err) {
45 | console.error('Failed to save the post: ', err)
46 | }
47 | finally {
48 | setAddRequestStatus('idle')
49 | }
50 | }
51 | }
52 |
53 | const usersOptions = users.map(user => (
54 |
55 | {user.name}
56 |
57 | ))
58 |
59 | return (
60 |
89 | )
90 | }
--------------------------------------------------------------------------------
/src/features/posts/postsSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice, nanoid, createAsyncThunk } from '@reduxjs/toolkit'
2 | import { client } from '../../api/client'
3 |
4 |
5 | const initialState = {
6 | posts: [],
7 | status: 'idle',
8 | errors: null
9 | }
10 |
11 | export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
12 | const response = await client.get('/fakeApi/posts')
13 | return response.posts
14 | })
15 |
16 | /*************************************************************************************************
17 | Normal thunk function which can be used as normal action, and can include async function
18 | *************************************************************************************************
19 |
20 | EX.1
21 | (normal thunk function)
22 |
23 | const exampleThunkFunction = (dispatch, getState) => {
24 | const stateBefore = getState()
25 | console.log(`Counter before: ${stateBefore.counter}`)
26 | dispatch(increment())
27 | const stateAfter = getState()
28 | console.log(`Counter after: ${stateAfter.counter}`)
29 | }
30 |
31 | EX.2
32 | (thunk action creator returns thunk function)
33 |
34 | const logAndAdd = amount => {
35 | return (dispatch, getState) => {
36 | const stateBefore = getState()
37 | console.log(`Counter before: ${stateBefore.counter}`)
38 | dispatch(incrementByAmount(amount))
39 | const stateAfter = getState()
40 | console.log(`Counter after: ${stateAfter.counter}`)
41 | }
42 | }
43 |
44 | EX.3
45 | (async thunk function which is called redux thunk)
46 |
47 | const getRepoDetailsStarted = () => ({
48 | type: 'repoDetails/fetchStarted'
49 | })
50 | const getRepoDetailsSuccess = repoDetails => ({
51 | type: 'repoDetails/fetchSucceeded',
52 | payload: repoDetails
53 | })
54 | const getRepoDetailsFailed = error => ({
55 | type: 'repoDetails/fetchFailed',
56 | error
57 | })
58 |
59 | const fetchIssuesCount = (org, repo) => async dispatch => {
60 | dispatch(getRepoDetailsStarted())
61 | try {
62 | const repoDetails = await getRepoDetails(org, repo)
63 | dispatch(getRepoDetailsSuccess(repoDetails))
64 | } catch (err) {
65 | dispatch(getRepoDetailsFailed(err.toString()))
66 | }
67 | }
68 |
69 | ************************************************************************************************/
70 |
71 | /*
72 | Create AsyncThunk using createAsyncThunk function in redux toolkit.
73 | This function returns 'action creator'.
74 | Created actions have action type of 'pending', 'fulfilled', 'rejected'
75 | And is used in extraReducers in slice
76 | */
77 |
78 | export const addNewPost = createAsyncThunk('posts/addNewPost', async initialPost => {
79 | const response = await client.post('/fakeApi/posts', { post: initialPost })
80 | return response.post
81 | })
82 |
83 | const postSlice = createSlice({
84 | name: 'posts',
85 | initialState,
86 | reducers: {
87 | reactionAdded(state, action) {
88 | const { postId, reaction } = action.payload
89 | const existingPost = state.posts.find(post => post.id === postId)
90 | if (existingPost) {
91 | existingPost.reactions[reaction]++
92 | }
93 | },
94 | postAdded: {
95 | reducer(state, action) {
96 | state.posts.push(action.payload)
97 | },
98 | prepare(title, content, userId) {
99 | return {
100 | payload: {
101 | id: nanoid(),
102 | date: new Date().toISOString(),
103 | title,
104 | content,
105 | user: userId,
106 | reactions: { thumbsUp: 0, hooray: 0 }
107 | },
108 | }
109 | },
110 | },
111 | postUpdated(state, action) {
112 | const { id, title, content } = action.payload
113 | const existingPost = state.posts.find(item => item.id === id)
114 | if (existingPost) {
115 | existingPost.title = title
116 | existingPost.content = content
117 | }
118 | }
119 | },
120 | extraReducers: {
121 | [fetchPosts.pending]: (state, action) => {
122 | state.status = 'loading'
123 | },
124 | [fetchPosts.fulfilled]: (state, action) => {
125 | state.status = 'completed'
126 | state.posts = state.posts.concat(action.payload)
127 | },
128 | [fetchPosts.rejected]: (state, action) => {
129 | state.status = 'failed'
130 | state.errors = action.errors.message
131 | },
132 | [addNewPost.fulfilled]: (state, action) => {
133 | state.posts.push(action.payload)
134 | }
135 | }
136 | })
137 |
138 | export const { reactionAdded, postAdded, postUpdated } = postSlice.actions
139 |
140 | export const selectAllPosts = state => state.posts.posts
141 | export const selectPostById = (state, postID) => state.posts.posts.find(
142 | post => post.id === postID
143 | )
144 | export default postSlice.reducer
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --redux-color: #764abc;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
9 | sans-serif;
10 | -webkit-font-smoothing: antialiased;
11 | -moz-osx-font-smoothing: grayscale;
12 | }
13 |
14 | code {
15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
16 | monospace;
17 | }
18 |
19 | p {
20 | font-size: 1.1rem;
21 | }
22 |
23 | /* Navbar */
24 |
25 | nav {
26 | display: flex;
27 | padding: 0;
28 | background: var(--redux-color);
29 | }
30 |
31 | nav section {
32 | width: 100%;
33 | }
34 |
35 | nav section h1,
36 | nav section {
37 | color: white;
38 | }
39 |
40 | nav a,
41 | nav a:active {
42 | font-weight: 700;
43 | padding: 0.25rem 1.5rem;
44 | border-radius: 4px;
45 | color: white !important;
46 | background: #481499;
47 | }
48 |
49 | nav a:first-of-type {
50 | margin-left: -1.5rem;
51 | }
52 |
53 | nav a:hover {
54 | color: white;
55 | background: #926bcf;
56 | }
57 |
58 | .navContent {
59 | display: flex;
60 | justify-content: space-between;
61 | }
62 |
63 | .navLinks {
64 | display: flex;
65 | }
66 |
67 | .navLinks a {
68 | margin-left: 5px;
69 | }
70 |
71 | .navLinks a .badge {
72 | margin-left: 5px;
73 | position: relative;
74 | top: -3px;
75 | }
76 |
77 | .badge {
78 | display: inline-block;
79 | padding: 0.25em 0.4em;
80 | font-size: 75%;
81 | font-weight: 700;
82 | line-height: 1;
83 | text-align: center;
84 | white-space: nowrap;
85 | vertical-align: baseline;
86 | border-radius: 0.25rem;
87 | color: #212529;
88 | background-color: #f8f9fa;
89 | }
90 |
91 | .navLinks :first-child {
92 | margin-left: 0;
93 | }
94 |
95 | /* Main content */
96 |
97 | section {
98 | max-width: 800px;
99 | margin-left: auto;
100 | margin-right: auto;
101 | padding: 0 1.5rem;
102 | }
103 |
104 | section h1 {
105 | font-size: 3rem;
106 | }
107 |
108 | /* Posts list */
109 |
110 | .post h2 {
111 | font-size: 2.5rem;
112 | margin-bottom: 5px;
113 | }
114 |
115 | .post-excerpt {
116 | padding: 2rem;
117 | border: 1px solid rgb(177, 174, 174);
118 | border-radius: 7px;
119 | }
120 |
121 | .posts-list .post-excerpt + .post-excerpt {
122 | margin-top: 0.5rem;
123 | }
124 |
125 | .post-excerpt h3 {
126 | margin: 0;
127 | font-size: 1.5rem;
128 | }
129 |
130 | p.post-content {
131 | margin-top: 10px;
132 | }
133 |
134 | .button {
135 | display: inline-block;
136 | background: #1976d2;
137 | color: white;
138 | border-radius: 4px;
139 | font-weight: 700;
140 | padding: 0.75rem 1.5rem;
141 | }
142 |
143 | button:disabled,
144 | button:disabled:hover {
145 | opacity: 0.5;
146 | }
147 |
148 | button.reaction-button {
149 | border: 1px solid #e0e3e9;
150 | padding: 5px 10px;
151 | margin: 5px 6px 10px;
152 | border-radius: 4px;
153 | white-space: nowrap;
154 | }
155 |
156 | /* https://projects.lukehaas.me/css-loaders/ , Loader #3 */
157 | .loader {
158 | font-size: 10px;
159 | margin: 50px auto;
160 | text-indent: -9999em;
161 | width: 5em;
162 | height: 5em;
163 | border-radius: 50%;
164 | background: var(--redux-color);
165 | background: -moz-linear-gradient(
166 | left,
167 | var(--redux-color) 10%,
168 | rgba(128, 0, 255, 0) 42%
169 | );
170 | background: -webkit-linear-gradient(
171 | left,
172 | var(--redux-color) 10%,
173 | rgba(128, 0, 255, 0) 42%
174 | );
175 | background: -o-linear-gradient(
176 | left,
177 | var(--redux-color) 10%,
178 | rgba(128, 0, 255, 0) 42%
179 | );
180 | background: -ms-linear-gradient(
181 | left,
182 | var(--redux-color) 10%,
183 | rgba(128, 0, 255, 0) 42%
184 | );
185 | background: linear-gradient(
186 | to right,
187 | var(--redux-color) 10%,
188 | rgba(128, 0, 255, 0) 42%
189 | );
190 | position: relative;
191 | -webkit-animation: load3 1.4s infinite linear;
192 | animation: load3 1.4s infinite linear;
193 | -webkit-transform: translateZ(0);
194 | -ms-transform: translateZ(0);
195 | transform: translateZ(0);
196 | }
197 |
198 | .loader:before {
199 | width: 50%;
200 | height: 50%;
201 | background: var(--redux-color);
202 | border-radius: 100% 0 0 0;
203 | position: absolute;
204 | top: 0;
205 | left: 0;
206 | content: '';
207 | }
208 |
209 | .loader:after {
210 | background: #ffffff;
211 | width: 75%;
212 | height: 75%;
213 | border-radius: 50%;
214 | content: '';
215 | margin: auto;
216 | position: absolute;
217 | top: 0;
218 | left: 0;
219 | bottom: 0;
220 | right: 0;
221 | }
222 |
223 | @-webkit-keyframes load3 {
224 | 0% {
225 | -webkit-transform: rotate(0deg);
226 | transform: rotate(0deg);
227 | }
228 | 100% {
229 | -webkit-transform: rotate(360deg);
230 | transform: rotate(360deg);
231 | }
232 | }
233 |
234 | @keyframes load3 {
235 | 0% {
236 | -webkit-transform: rotate(0deg);
237 | transform: rotate(0deg);
238 | }
239 | 100% {
240 | -webkit-transform: rotate(360deg);
241 | transform: rotate(360deg);
242 | }
243 | }
244 |
245 | /* Notifications list */
246 |
247 | .notification {
248 | border: 1px solid #eee;
249 | padding: 0.5rem;
250 | }
251 |
252 | .notificationsList .notification + .notification {
253 | border-top: none;
254 | }
255 |
256 | .notification.new {
257 | background-color: rgba(29, 161, 242, 0.1);
258 | }
259 |
--------------------------------------------------------------------------------
/src/api/server.js:
--------------------------------------------------------------------------------
1 | import {
2 | Server,
3 | Model,
4 | Factory,
5 | belongsTo,
6 | hasMany,
7 | association,
8 | RestSerializer,
9 | } from 'miragejs'
10 |
11 | import { nanoid } from '@reduxjs/toolkit'
12 |
13 | import faker from 'faker'
14 | import { sentence, paragraph, article, setRandom } from 'txtgen'
15 | import { parseISO } from 'date-fns'
16 | import seedrandom from 'seedrandom'
17 |
18 | const IdSerializer = RestSerializer.extend({
19 | serializeIds: 'always',
20 | })
21 |
22 | // Set up a seeded random number generator, so that we get
23 | // a consistent set of users / entries each time the page loads.
24 | // This can be reset by deleting this localStorage value,
25 | // or turned off by setting `useSeededRNG` to false.
26 | let useSeededRNG = false
27 |
28 | let rng = seedrandom()
29 |
30 | if (useSeededRNG) {
31 | let randomSeedString = localStorage.getItem('randomTimestampSeed')
32 | let seedDate
33 |
34 | if (randomSeedString) {
35 | seedDate = new Date(randomSeedString)
36 | } else {
37 | seedDate = new Date()
38 | randomSeedString = seedDate.toISOString()
39 | localStorage.setItem('randomTimestampSeed', randomSeedString)
40 | }
41 |
42 | rng = seedrandom(randomSeedString)
43 | setRandom(rng)
44 | faker.seed(seedDate.getTime())
45 | }
46 |
47 | function getRandomInt(min, max) {
48 | min = Math.ceil(min)
49 | max = Math.floor(max)
50 | return Math.floor(rng() * (max - min + 1)) + min
51 | }
52 |
53 | const randomFromArray = (array) => {
54 | const index = getRandomInt(0, array.length - 1)
55 | return array[index]
56 | }
57 |
58 | const notificationTemplates = [
59 | 'poked you',
60 | 'says hi!',
61 | `is glad we're friends`,
62 | 'sent you a gift',
63 | ]
64 |
65 | new Server({
66 | routes() {
67 | this.namespace = 'fakeApi'
68 | // this.timing = 2000
69 |
70 | this.resource('users')
71 | this.resource('posts')
72 | this.resource('comments')
73 |
74 | const server = this
75 |
76 | this.post('/posts', function (schema, req) {
77 | const data = this.normalizedRequestAttrs()
78 | data.date = new Date().toISOString()
79 | // Work around some odd behavior by Mirage that's causing an extra
80 | // user entry to be created unexpectedly when we only supply a userId.
81 | // It really want an entire Model passed in as data.user for some reason.
82 | const user = schema.users.find(data.userId)
83 | data.user = user
84 |
85 | if (data.content === 'error') {
86 | throw new Error('Could not save the post!')
87 | }
88 |
89 | const result = server.create('post', data)
90 | return result
91 | })
92 |
93 | this.get('/posts/:postId/comments', (schema, req) => {
94 | const post = schema.posts.find(req.params.postId)
95 | return post.comments
96 | })
97 |
98 | this.get('/notifications', (schema, req) => {
99 | const numNotifications = getRandomInt(1, 5)
100 |
101 | let pastDate
102 |
103 | const now = new Date()
104 |
105 | if (req.queryParams.since) {
106 | pastDate = parseISO(req.queryParams.since)
107 | } else {
108 | pastDate = new Date(now.valueOf())
109 | pastDate.setMinutes(pastDate.getMinutes() - 15)
110 | }
111 |
112 | // Create N random notifications. We won't bother saving these
113 | // in the DB - just generate a new batch and return them.
114 | const notifications = [...Array(numNotifications)].map(() => {
115 | const user = randomFromArray(schema.db.users)
116 | const template = randomFromArray(notificationTemplates)
117 | return {
118 | id: nanoid(),
119 | date: faker.date.between(pastDate, now).toISOString(),
120 | message: template,
121 | user: user.id,
122 | read: false,
123 | isNew: true,
124 | }
125 | })
126 |
127 | return { notifications }
128 | })
129 | },
130 | models: {
131 | user: Model.extend({
132 | posts: hasMany(),
133 | }),
134 | post: Model.extend({
135 | user: belongsTo(),
136 | comments: hasMany(),
137 | }),
138 | comment: Model.extend({
139 | post: belongsTo(),
140 | }),
141 | notification: Model.extend({}),
142 | },
143 | factories: {
144 | user: Factory.extend({
145 | id() {
146 | return nanoid()
147 | },
148 | firstName() {
149 | return faker.name.firstName()
150 | },
151 | lastName() {
152 | return faker.name.lastName()
153 | },
154 | name() {
155 | return faker.name.findName(this.firstName, this.lastName)
156 | },
157 | username() {
158 | return faker.internet.userName(this.firstName, this.lastName)
159 | },
160 |
161 | afterCreate(user, server) {
162 | server.createList('post', 3, { user })
163 | },
164 | }),
165 | post: Factory.extend({
166 | id() {
167 | return nanoid()
168 | },
169 | title() {
170 | return sentence()
171 | },
172 | date() {
173 | return faker.date.recent(7)
174 | },
175 | content() {
176 | return article(1)
177 | },
178 | reactions() {
179 | return {
180 | thumbsUp: 0,
181 | hooray: 0,
182 | heart: 0,
183 | rocket: 0,
184 | eyes: 0,
185 | }
186 | },
187 | afterCreate(post, server) {
188 | //server.createList('comment', 3, { post })
189 | },
190 |
191 | user: association(),
192 | }),
193 | comment: Factory.extend({
194 | id() {
195 | return nanoid()
196 | },
197 | date() {
198 | return faker.date.past(2)
199 | },
200 | text() {
201 | return paragraph()
202 | },
203 | post: association(),
204 | }),
205 | },
206 | serializers: {
207 | user: IdSerializer,
208 | post: IdSerializer,
209 | comment: IdSerializer,
210 | },
211 | seeds(server) {
212 | server.createList('user', 3)
213 | },
214 | })
215 |
--------------------------------------------------------------------------------
/public/primitiveui.css:
--------------------------------------------------------------------------------
1 | /*!
2 | * Primitive UI | MIT License
3 | *
4 | * A minimalist front-end design toolkit built with Sass for developing
5 | * responsive, browser-consistent web apps.
6 | *
7 | * Author: Tania Rascia
8 | * Source: https://github.com/taniarascia/primitive
9 | * Documentation: https://taniarascia.github.io/primitive
10 | */
11 | /**
12 | * Variables
13 | *
14 | * The majority of the configuration for the toolkit.
15 | */
16 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
17 | /* Document
18 | ========================================================================== */
19 | /**
20 | * 1. Correct the line height in all browsers.
21 | * 2. Prevent adjustments of font size after orientation changes in iOS.
22 | */
23 | html {
24 | line-height: 1.15;
25 | /* 1 */
26 | -webkit-text-size-adjust: 100%;
27 | /* 2 */
28 | }
29 |
30 | /* Sections
31 | ========================================================================== */
32 | /**
33 | * Remove the margin in all browsers.
34 | */
35 | body {
36 | margin: 0;
37 | }
38 |
39 | /**
40 | * Render the `main` element consistently in IE.
41 | */
42 | main {
43 | display: block;
44 | }
45 |
46 | /**
47 | * Correct the font size and margin on `h1` elements within `section` and
48 | * `article` contexts in Chrome, Firefox, and Safari.
49 | */
50 | h1 {
51 | font-size: 2em;
52 | margin: 0.67em 0;
53 | }
54 |
55 | /* Grouping content
56 | ========================================================================== */
57 | /**
58 | * 1. Add the correct box sizing in Firefox.
59 | * 2. Show the overflow in Edge and IE.
60 | */
61 | hr {
62 | box-sizing: content-box;
63 | /* 1 */
64 | height: 0;
65 | /* 1 */
66 | overflow: visible;
67 | /* 2 */
68 | }
69 |
70 | /**
71 | * 1. Correct the inheritance and scaling of font size in all browsers.
72 | * 2. Correct the odd `em` font sizing in all browsers.
73 | */
74 | pre {
75 | font-family: monospace, monospace;
76 | /* 1 */
77 | font-size: 1em;
78 | /* 2 */
79 | }
80 |
81 | /* Text-level semantics
82 | ========================================================================== */
83 | /**
84 | * Remove the gray background on active links in IE 10.
85 | */
86 | a {
87 | background-color: transparent;
88 | }
89 |
90 | /**
91 | * 1. Remove the bottom border in Chrome 57-
92 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
93 | */
94 | abbr[title] {
95 | border-bottom: none;
96 | /* 1 */
97 | text-decoration: underline;
98 | /* 2 */
99 | text-decoration: underline dotted;
100 | /* 2 */
101 | }
102 |
103 | /**
104 | * Add the correct font weight in Chrome, Edge, and Safari.
105 | */
106 | b,
107 | strong {
108 | font-weight: bolder;
109 | }
110 |
111 | /**
112 | * 1. Correct the inheritance and scaling of font size in all browsers.
113 | * 2. Correct the odd `em` font sizing in all browsers.
114 | */
115 | code,
116 | kbd,
117 | samp {
118 | font-family: monospace, monospace;
119 | /* 1 */
120 | font-size: 1em;
121 | /* 2 */
122 | }
123 |
124 | /**
125 | * Add the correct font size in all browsers.
126 | */
127 | small {
128 | font-size: 80%;
129 | }
130 |
131 | /**
132 | * Prevent `sub` and `sup` elements from affecting the line height in
133 | * all browsers.
134 | */
135 | sub,
136 | sup {
137 | font-size: 75%;
138 | line-height: 0;
139 | position: relative;
140 | vertical-align: baseline;
141 | }
142 |
143 | sub {
144 | bottom: -0.25em;
145 | }
146 |
147 | sup {
148 | top: -0.5em;
149 | }
150 |
151 | /* Embedded content
152 | ========================================================================== */
153 | /**
154 | * Remove the border on images inside links in IE 10.
155 | */
156 | img {
157 | border-style: none;
158 | }
159 |
160 | /* Forms
161 | ========================================================================== */
162 | /**
163 | * 1. Change the font styles in all browsers.
164 | * 2. Remove the margin in Firefox and Safari.
165 | */
166 | button,
167 | input,
168 | optgroup,
169 | select,
170 | textarea {
171 | font-family: inherit;
172 | /* 1 */
173 | font-size: 100%;
174 | /* 1 */
175 | line-height: 1.15;
176 | /* 1 */
177 | margin: 0;
178 | /* 2 */
179 | }
180 |
181 | /**
182 | * Show the overflow in IE.
183 | * 1. Show the overflow in Edge.
184 | */
185 | button,
186 | input {
187 | /* 1 */
188 | overflow: visible;
189 | }
190 |
191 | /**
192 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
193 | * 1. Remove the inheritance of text transform in Firefox.
194 | */
195 | button,
196 | select {
197 | /* 1 */
198 | text-transform: none;
199 | }
200 |
201 | /**
202 | * Correct the inability to style clickable types in iOS and Safari.
203 | */
204 | button,
205 | [type='button'],
206 | [type='reset'],
207 | [type='submit'] {
208 | -webkit-appearance: button;
209 | }
210 |
211 | /**
212 | * Remove the inner border and padding in Firefox.
213 | */
214 | button::-moz-focus-inner,
215 | [type='button']::-moz-focus-inner,
216 | [type='reset']::-moz-focus-inner,
217 | [type='submit']::-moz-focus-inner {
218 | border-style: none;
219 | padding: 0;
220 | }
221 |
222 | /**
223 | * Restore the focus styles unset by the previous rule.
224 | */
225 | button:-moz-focusring,
226 | [type='button']:-moz-focusring,
227 | [type='reset']:-moz-focusring,
228 | [type='submit']:-moz-focusring {
229 | outline: 1px dotted ButtonText;
230 | }
231 |
232 | /**
233 | * Correct the padding in Firefox.
234 | */
235 | fieldset {
236 | padding: 0.35em 0.75em 0.625em;
237 | }
238 |
239 | /**
240 | * 1. Correct the text wrapping in Edge and IE.
241 | * 2. Correct the color inheritance from `fieldset` elements in IE.
242 | * 3. Remove the padding so developers are not caught out when they zero out
243 | * `fieldset` elements in all browsers.
244 | */
245 | legend {
246 | box-sizing: border-box;
247 | /* 1 */
248 | color: inherit;
249 | /* 2 */
250 | display: table;
251 | /* 1 */
252 | max-width: 100%;
253 | /* 1 */
254 | padding: 0;
255 | /* 3 */
256 | white-space: normal;
257 | /* 1 */
258 | }
259 |
260 | /**
261 | * Add the correct vertical alignment in Chrome, Firefox, and Opera.
262 | */
263 | progress {
264 | vertical-align: baseline;
265 | }
266 |
267 | /**
268 | * Remove the default vertical scrollbar in IE 10+.
269 | */
270 | textarea {
271 | overflow: auto;
272 | }
273 |
274 | /**
275 | * 1. Add the correct box sizing in IE 10.
276 | * 2. Remove the padding in IE 10.
277 | */
278 | [type='checkbox'],
279 | [type='radio'] {
280 | box-sizing: border-box;
281 | /* 1 */
282 | padding: 0;
283 | /* 2 */
284 | }
285 |
286 | /**
287 | * Correct the cursor style of increment and decrement buttons in Chrome.
288 | */
289 | [type='number']::-webkit-inner-spin-button,
290 | [type='number']::-webkit-outer-spin-button {
291 | height: auto;
292 | }
293 |
294 | /**
295 | * 1. Correct the odd appearance in Chrome and Safari.
296 | * 2. Correct the outline style in Safari.
297 | */
298 | [type='search'] {
299 | -webkit-appearance: textfield;
300 | /* 1 */
301 | outline-offset: -2px;
302 | /* 2 */
303 | }
304 |
305 | /**
306 | * Remove the inner padding in Chrome and Safari on macOS.
307 | */
308 | [type='search']::-webkit-search-decoration {
309 | -webkit-appearance: none;
310 | }
311 |
312 | /**
313 | * 1. Correct the inability to style clickable types in iOS and Safari.
314 | * 2. Change font properties to `inherit` in Safari.
315 | */
316 | ::-webkit-file-upload-button {
317 | -webkit-appearance: button;
318 | /* 1 */
319 | font: inherit;
320 | /* 2 */
321 | }
322 |
323 | /* Interactive
324 | ========================================================================== */
325 | /*
326 | * Add the correct display in Edge, IE 10+, and Firefox.
327 | */
328 | details {
329 | display: block;
330 | }
331 |
332 | /*
333 | * Add the correct display in all browsers.
334 | */
335 | summary {
336 | display: list-item;
337 | }
338 |
339 | /* Misc
340 | ========================================================================== */
341 | /**
342 | * Add the correct display in IE 10+.
343 | */
344 | template {
345 | display: none;
346 | }
347 |
348 | /**
349 | * Add the correct display in IE 10.
350 | */
351 | [hidden] {
352 | display: none;
353 | }
354 |
355 | html {
356 | box-sizing: border-box;
357 | }
358 |
359 | *,
360 | *::before,
361 | *::after {
362 | box-sizing: inherit;
363 | }
364 |
365 | figure {
366 | margin: 0;
367 | }
368 |
369 | /**
370 | * Layout
371 | */
372 | html {
373 | -webkit-font-smoothing: antialiased;
374 | -moz-osx-font-smoothing: grayscale;
375 | font: normal normal normal 1rem/1.6 -apple-system, BlinkMacSystemFont,
376 | Helvetica Neue, Helvetica, Arial, sans-serif;
377 | font-size: 1rem;
378 | }
379 |
380 | body {
381 | color: #404040;
382 | background: white;
383 | font-size: 1rem;
384 | }
385 |
386 | p,
387 | ol,
388 | ul,
389 | dl,
390 | table {
391 | margin: 0 0 1.5rem 0;
392 | }
393 |
394 | ul li ul {
395 | margin-bottom: 0;
396 | }
397 |
398 | ol li ol {
399 | margin-bottom: 0;
400 | }
401 |
402 | h1,
403 | h2,
404 | h3,
405 | h4,
406 | h5 {
407 | margin: 1.5rem 0;
408 | font-weight: 600;
409 | font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica,
410 | Arial, sans-serif;
411 | line-height: 1.2;
412 | color: #404040;
413 | }
414 | h1:not(:first-child),
415 | h2:not(:first-child),
416 | h3:not(:first-child),
417 | h4:not(:first-child),
418 | h5:not(:first-child) {
419 | margin: 1.5rem 0;
420 | }
421 |
422 | h1:not(:first-child),
423 | h2:not(:first-child),
424 | h3:not(:first-child) {
425 | margin-top: 2rem;
426 | }
427 |
428 | h1 {
429 | font-size: 1.75rem;
430 | }
431 |
432 | h2 {
433 | font-size: 1.5rem;
434 | }
435 |
436 | h3 {
437 | font-size: 1.25rem;
438 | }
439 |
440 | h4 {
441 | font-size: 1.1rem;
442 | }
443 |
444 | h5 {
445 | font-size: 1rem;
446 | }
447 |
448 | @media (min-width: 600px) {
449 | h1:not(:first-child),
450 | h2:not(:first-child),
451 | h3:not(:first-child) {
452 | margin-top: 2.5rem;
453 | }
454 | h1 {
455 | font-size: 2.25rem;
456 | }
457 | h2 {
458 | font-size: 2rem;
459 | }
460 | h3 {
461 | font-size: 1.75rem;
462 | }
463 | h4 {
464 | font-size: 1.5rem;
465 | }
466 | h5 {
467 | font-size: 1.25rem;
468 | }
469 | }
470 |
471 | a {
472 | color: #0366ee;
473 | text-decoration: none;
474 | }
475 | a:hover,
476 | a:active,
477 | a:focus {
478 | color: #0246a2;
479 | text-decoration: underline;
480 | }
481 |
482 | mark {
483 | background: #ffeea8;
484 | padding: 0 0.2rem;
485 | }
486 |
487 | blockquote {
488 | margin: 0 0 1.5rem 0;
489 | border-left: 16px solid #f0f0f0;
490 | padding: 0 1.5rem;
491 | font-size: 1.5rem;
492 | }
493 | blockquote cite {
494 | display: block;
495 | margin-top: 1.5rem;
496 | font-size: 1rem;
497 | text-align: right;
498 | }
499 |
500 | pre {
501 | border: 0;
502 | border-radius: 4px;
503 | background: transparent;
504 | padding: 1rem;
505 | tab-size: 2;
506 | color: #404040;
507 | font-family: Menlo, monospace;
508 | font-size: 14px;
509 | margin: 0 0 1.5rem 0;
510 | }
511 | pre code {
512 | font-family: Menlo, monospace;
513 | line-height: 1.2;
514 | }
515 |
516 | kbd {
517 | background-color: #f7f7f7;
518 | border: 1px solid #ccc;
519 | border-radius: 3px;
520 | box-shadow: 0 1px 0 rgba(0, 0, 0, 0.2), 0 0 0 2px #fff inset;
521 | color: #333;
522 | display: inline-block;
523 | font-family: Helvetica, Arial, sans-serif;
524 | font-size: 13px;
525 | line-height: 1.4;
526 | margin: 0 0.1em;
527 | padding: 0.1em 0.6em;
528 | text-shadow: 0 1px 0 #fff;
529 | }
530 |
531 | :not(pre) > code {
532 | color: #404040;
533 | background: transparent;
534 | font-family: Menlo, monospace;
535 | font-size: 14px;
536 | padding: 0 0.2rem;
537 | border: 1px solid #dedede;
538 | border-radius: 4px;
539 | }
540 |
541 | hr {
542 | height: 0;
543 | border: 0;
544 | border-top: 1px solid #dedede;
545 | }
546 |
547 | dt {
548 | font-weight: 600;
549 | }
550 |
551 | dd {
552 | margin-bottom: 0.5rem;
553 | }
554 |
555 | .full-container {
556 | max-width: 100%;
557 | padding: 0 1rem;
558 | }
559 |
560 | .container,
561 | .small-container,
562 | .medium-container {
563 | max-width: 1200px;
564 | padding: 0 1rem;
565 | margin-left: auto;
566 | margin-right: auto;
567 | }
568 |
569 | .small-container {
570 | max-width: 800px;
571 | }
572 |
573 | .medium-container {
574 | max-width: 1000px;
575 | }
576 |
577 | .content-section {
578 | padding: 30px 0;
579 | }
580 |
581 | @media (min-width: 600px) {
582 | .content-section {
583 | padding: 60px 0;
584 | }
585 | }
586 |
587 | /**
588 | * Grid
589 | */
590 | .flex-small,
591 | .flex-large {
592 | padding-left: 1rem;
593 | padding-right: 1rem;
594 | }
595 |
596 | .flex-row {
597 | margin-left: -1rem;
598 | margin-right: -1rem;
599 | }
600 |
601 | .flex-row {
602 | display: flex;
603 | flex-direction: row;
604 | flex-wrap: wrap;
605 | }
606 |
607 | .flex-small,
608 | .flex-large {
609 | flex-basis: 100%;
610 | margin-bottom: 1rem;
611 | }
612 |
613 | /* Small screen breakpoint */
614 | @media (min-width: 600px) {
615 | .flex-small {
616 | flex: 1;
617 | margin-bottom: 0;
618 | }
619 | }
620 |
621 | /* Large screen breakpoint */
622 | @media (min-width: 1000px) {
623 | .flex-large {
624 | flex: 1;
625 | margin-bottom: 0;
626 | }
627 | }
628 |
629 | /**
630 | * Helpers
631 | */
632 | .clearfix::before,
633 | .clearfix::after {
634 | content: ' ';
635 | display: block;
636 | }
637 |
638 | .clearfix:after {
639 | clear: both;
640 | }
641 |
642 | .text-left {
643 | text-align: left;
644 | }
645 |
646 | .text-right {
647 | text-align: right;
648 | }
649 |
650 | .text-center {
651 | text-align: center;
652 | }
653 |
654 | .text-justify {
655 | text-align: justify;
656 | }
657 |
658 | .block {
659 | display: block;
660 | }
661 |
662 | .inline-block {
663 | display: inline-block;
664 | }
665 |
666 | .inline {
667 | display: inline;
668 | }
669 |
670 | .vertical-center {
671 | display: flex;
672 | align-items: center;
673 | justify-content: center;
674 | }
675 |
676 | .responsive-image {
677 | max-width: 100%;
678 | height: auto;
679 | }
680 |
681 | .show {
682 | display: block !important;
683 | }
684 |
685 | .hide {
686 | display: none !important;
687 | }
688 |
689 | .invisible {
690 | visibility: hidden;
691 | }
692 |
693 | .float-left {
694 | float: left;
695 | }
696 |
697 | .float-right {
698 | float: right;
699 | }
700 |
701 | .no-padding-top {
702 | padding-top: 0;
703 | }
704 |
705 | .no-padding-bottom {
706 | padding-bottom: 0;
707 | }
708 |
709 | .padding-top {
710 | padding-top: 2rem;
711 | }
712 |
713 | .padding-bottom {
714 | padding-bottom: 2rem;
715 | }
716 |
717 | .no-margin-top {
718 | margin-top: 0;
719 | }
720 |
721 | .no-margin-bottom {
722 | margin-bottom: 0;
723 | }
724 |
725 | .margin-top {
726 | margin-top: 2rem;
727 | }
728 |
729 | .margin-bottom {
730 | margin-bottom: 2rem;
731 | }
732 |
733 | .alternate-background {
734 | background: #fafafa;
735 | color: #404040;
736 | }
737 |
738 | .screen-reader-text {
739 | clip: rect(1px, 1px, 1px, 1px);
740 | position: absolute !important;
741 | height: 1px;
742 | width: 1px;
743 | overflow: hidden;
744 | }
745 |
746 | /**
747 | * Buttons
748 | */
749 | .button,
750 | a.button,
751 | button,
752 | [type='submit'],
753 | [type='reset'],
754 | [type='button'] {
755 | -webkit-appearance: none;
756 | display: inline-block;
757 | border: 1px solid #0366ee;
758 | border-radius: 4px;
759 | background: #0366ee;
760 | color: #ffffff;
761 | font-weight: 600;
762 | font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, Helvetica,
763 | Arial, sans-serif;
764 | font-size: 1rem;
765 | text-transform: none;
766 | padding: 0.75rem 1.25rem;
767 | margin: 0 0 0.5rem 0;
768 | vertical-align: middle;
769 | text-align: center;
770 | cursor: pointer;
771 | text-decoration: none;
772 | line-height: 1;
773 | }
774 |
775 | .button:hover,
776 | a.button:hover,
777 | button:hover,
778 | [type='submit']:hover,
779 | [type='reset']:hover,
780 | [type='button']:hover {
781 | border: 1px solid #0250bc;
782 | background: #0250bc;
783 | color: #ffffff;
784 | text-decoration: none;
785 | }
786 |
787 | .button:focus,
788 | .button:active,
789 | a.button:focus,
790 | a.button:active,
791 | button:focus,
792 | button:active,
793 | [type='submit']:focus,
794 | [type='submit']:active,
795 | [type='reset']:focus,
796 | [type='reset']:active,
797 | [type='button']:focus,
798 | [type='button']:active {
799 | border: 1px solid #0250bc;
800 | background: #0250bc;
801 | color: #ffffff;
802 | text-decoration: none;
803 | }
804 |
805 | .button::-moz-focus-inner,
806 | a.button::-moz-focus-inner,
807 | button::-moz-focus-inner,
808 | [type='submit']::-moz-focus-inner,
809 | [type='reset']::-moz-focus-inner,
810 | [type='button']::-moz-focus-inner {
811 | border: 0;
812 | padding: 0;
813 | }
814 |
815 | .accent-button,
816 | a.accent-button {
817 | color: #ffffff;
818 | border: 1px solid #29de7d;
819 | background: #29de7d;
820 | }
821 | .accent-button:hover,
822 | .accent-button:focus,
823 | .accent-button:active,
824 | a.accent-button:hover,
825 | a.accent-button:focus,
826 | a.accent-button:active {
827 | color: #ffffff;
828 | border: 1px solid #1cb864;
829 | background: #1cb864;
830 | }
831 |
832 | .muted-button,
833 | a.muted-button {
834 | background: transparent;
835 | border: 1px solid #cdcdcd;
836 | color: #4e4e4e;
837 | }
838 | .muted-button:hover,
839 | .muted-button:focus,
840 | .muted-button:active,
841 | a.muted-button:hover,
842 | a.muted-button:focus,
843 | a.muted-button:active {
844 | color: #4e4e4e;
845 | border: 1px solid #818181;
846 | background: transparent;
847 | }
848 |
849 | .round-button,
850 | a.round-button {
851 | border-radius: 40px;
852 | }
853 |
854 | .square-button,
855 | a.square-button {
856 | border-radius: 0;
857 | }
858 |
859 | .full-button,
860 | a.full-button {
861 | display: block;
862 | width: 100%;
863 | }
864 |
865 | /**
866 | * Forms
867 | */
868 | [type='color'],
869 | [type='date'],
870 | [type='datetime'],
871 | [type='datetime-local'],
872 | [type='email'],
873 | [type='month'],
874 | [type='number'],
875 | [type='password'],
876 | [type='search'],
877 | [type='tel'],
878 | [type='text'],
879 | [type='url'],
880 | [type='week'],
881 | [type='time'],
882 | select,
883 | textarea {
884 | display: block;
885 | border: 1px solid #dedede;
886 | border-radius: 4px;
887 | padding: 0.75rem;
888 | outline: none;
889 | background: transparent;
890 | margin-bottom: 0.5rem;
891 | font-size: 1rem;
892 | width: 100%;
893 | max-width: 100%;
894 | line-height: 1;
895 | }
896 |
897 | [type='color']:hover,
898 | [type='date']:hover,
899 | [type='datetime']:hover,
900 | [type='datetime-local']:hover,
901 | [type='email']:hover,
902 | [type='month']:hover,
903 | [type='number']:hover,
904 | [type='password']:hover,
905 | [type='search']:hover,
906 | [type='tel']:hover,
907 | [type='text']:hover,
908 | [type='url']:hover,
909 | [type='week']:hover,
910 | [type='time']:hover,
911 | select:hover,
912 | textarea:hover {
913 | border: 1px solid #c5c5c5;
914 | }
915 |
916 | [type='color']:focus,
917 | [type='color']:active,
918 | [type='date']:focus,
919 | [type='date']:active,
920 | [type='datetime']:focus,
921 | [type='datetime']:active,
922 | [type='datetime-local']:focus,
923 | [type='datetime-local']:active,
924 | [type='email']:focus,
925 | [type='email']:active,
926 | [type='month']:focus,
927 | [type='month']:active,
928 | [type='number']:focus,
929 | [type='number']:active,
930 | [type='password']:focus,
931 | [type='password']:active,
932 | [type='search']:focus,
933 | [type='search']:active,
934 | [type='tel']:focus,
935 | [type='tel']:active,
936 | [type='text']:focus,
937 | [type='text']:active,
938 | [type='url']:focus,
939 | [type='url']:active,
940 | [type='week']:focus,
941 | [type='week']:active,
942 | [type='time']:focus,
943 | [type='time']:active,
944 | select:focus,
945 | select:active,
946 | textarea:focus,
947 | textarea:active {
948 | border: 1px solid #0366ee;
949 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1), 0 0 6px #8cbcfe;
950 | }
951 |
952 | textarea {
953 | overflow: auto;
954 | height: auto;
955 | }
956 |
957 | fieldset {
958 | border: 1px solid #dedede;
959 | border-radius: 4px;
960 | padding: 1rem;
961 | margin: 1.5rem 0;
962 | }
963 |
964 | legend {
965 | padding: 0 0.5rem;
966 | font-weight: 600;
967 | }
968 |
969 | select {
970 | color: #404040;
971 | -webkit-appearance: none;
972 | -moz-appearance: none;
973 | background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAJCAYAAAA/33wPAAAAvklEQVQoFY2QMQqEMBBFv7ERa/EMXkGw11K8QbDXzuN4BHv7QO6ifUgj7v4UAdlVM8Uwf+b9YZJISnlqrfEUZVlinucnBGKaJgghbiHOyLyFKIoCbdvecpyReYvo/Ma2bajrGtbaC58kCdZ1RZ7nl/4/4d5EsO/7nzl7IUtodBexMMagaRrs+06JLMvcNWmaOv2W/C/TMAyD58dxROgSmvxFFMdxoOs6lliWBXEcuzokXRbRoJRyvqqqQvye+QDMDz1D6yuj9wAAAABJRU5ErkJggg==)
974 | right center no-repeat;
975 | line-height: 1;
976 | }
977 |
978 | select::-ms-expand {
979 | display: none;
980 | }
981 |
982 | [type='range'] {
983 | width: 100%;
984 | }
985 |
986 | label {
987 | font-weight: 600;
988 | max-width: 100%;
989 | display: block;
990 | margin: 1rem 0 0.5rem;
991 | }
992 |
993 | @media (min-width: 600px) {
994 | .split-form label {
995 | text-align: right;
996 | padding: 0 0.5rem;
997 | margin-bottom: 1rem;
998 | }
999 | }
1000 |
1001 | input.has-error,
1002 | input.has-error:hover,
1003 | input.has-error:focus,
1004 | input.has-error:active,
1005 | select.has-error,
1006 | select.has-error:hover,
1007 | select.has-error:focus,
1008 | select.has-error:active,
1009 | textarea.has-error,
1010 | textarea.has-error:hover,
1011 | textarea.has-error:focus,
1012 | textarea.has-error:active {
1013 | border: 1px solid #d33c40;
1014 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.1), 0 0 6px #f4cecf;
1015 | }
1016 |
1017 | ::-webkit-input-placeholder,
1018 | ::-moz-placeholder,
1019 | :-moz-placeholder,
1020 | :-ms-input-placeholder {
1021 | color: #9a9a9a;
1022 | }
1023 |
1024 | /**
1025 | * Tables
1026 | */
1027 | table {
1028 | border-collapse: collapse;
1029 | border-spacing: 0;
1030 | width: 100%;
1031 | max-width: 100%;
1032 | }
1033 |
1034 | thead th {
1035 | border-bottom: 2px solid #dedede;
1036 | }
1037 |
1038 | tfoot th {
1039 | border-top: 2px solid #dedede;
1040 | }
1041 |
1042 | td {
1043 | border-bottom: 1px solid #dedede;
1044 | }
1045 |
1046 | th,
1047 | td {
1048 | text-align: left;
1049 | padding: 0.5rem;
1050 | }
1051 |
1052 | caption {
1053 | padding: 1rem 0;
1054 | caption-side: bottom;
1055 | color: #ababab;
1056 | }
1057 |
1058 | .striped-table tbody tr:nth-child(odd) {
1059 | background-color: #f8f8f8;
1060 | }
1061 |
1062 | .contain-table {
1063 | overflow-x: auto;
1064 | }
1065 |
1066 | @media (min-width: 600px) {
1067 | .contain-table {
1068 | width: 100%;
1069 | }
1070 | }
1071 |
1072 | /*
1073 | * Layout
1074 | */
1075 | /**
1076 | * Layout
1077 | */
1078 |
--------------------------------------------------------------------------------