Added lesson 4

02_lesson
gitdagray 2022-04-05 16:31:56 -05:00
parent f10fae7298
commit c2fec6e6fe
49 changed files with 56142 additions and 2 deletions

View File

@ -1,5 +1,5 @@
{
"name": "02_lesson",
"name": "03_lesson",
"version": "0.1.0",
"private": true,
"dependencies": {

View File

@ -1,5 +1,5 @@
{
"name": "02_lesson",
"name": "03_lesson",
"version": "0.1.0",
"private": true,
"dependencies": {

23
04_lesson/.gitignore vendored 100644
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

27326
04_lesson/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{
"name": "04_lesson",
"version": "0.1.0",
"private": true,
"dependencies": {
"@reduxjs/toolkit": "^1.8.0",
"axios": "^0.26.1",
"date-fns": "^2.28.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.6",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -0,0 +1,26 @@
import PostsList from "./features/posts/PostsList";
import AddPostForm from "./features/posts/AddPostForm";
import SinglePostPage from "./features/posts/SinglePostPage";
import EditPostForm from "./features/posts/EditPostForm";
import Layout from "./components/Layout";
import { Routes, Route } from 'react-router-dom';
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<PostsList />} />
<Route path="post">
<Route index element={<AddPostForm />} />
<Route path=":postId" element={<SinglePostPage />} />
<Route path="edit/:postId" element={<EditPostForm />} />
</Route>
</Route>
</Routes>
);
}
export default App;

View File

@ -0,0 +1,11 @@
import { configureStore } from "@reduxjs/toolkit";
import postsReducer from '../features/posts/postsSlice';
import usersReducer from '../features/users/usersSlice';
export const store = configureStore({
reducer: {
posts: postsReducer,
users: usersReducer
}
})

View File

@ -0,0 +1,17 @@
import { Link } from "react-router-dom"
const Header = () => {
return (
<header className="Header">
<h1>Redux Blog</h1>
<nav>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="post">Post</Link></li>
</ul>
</nav>
</header>
)
}
export default Header

View File

@ -0,0 +1,15 @@
import { Outlet } from 'react-router-dom';
import Header from './Header';
const Layout = () => {
return (
<>
<Header />
<main className="App">
<Outlet />
</main>
</>
)
}
export default Layout

View File

@ -0,0 +1,85 @@
import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { addNewPost } from "./postsSlice";
import { selectAllUsers } from "../users/usersSlice";
import { useNavigate } from "react-router-dom";
const AddPostForm = () => {
const dispatch = useDispatch()
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [userId, setUserId] = useState('')
const [addRequestStatus, setAddRequestStatus] = useState('idle')
const users = useSelector(selectAllUsers)
const onTitleChanged = e => setTitle(e.target.value)
const onContentChanged = e => setContent(e.target.value)
const onAuthorChanged = e => setUserId(e.target.value)
const canSave = [title, content, userId].every(Boolean) && addRequestStatus === 'idle';
const onSavePostClicked = () => {
if (canSave) {
try {
setAddRequestStatus('pending')
dispatch(addNewPost({ title, body: content, userId })).unwrap()
setTitle('')
setContent('')
setUserId('')
navigate('/')
} catch (err) {
console.error('Failed to save the post', err)
} finally {
setAddRequestStatus('idle')
}
}
}
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Add a New Post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" value={userId} onChange={onAuthorChanged}>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
<button
type="button"
onClick={onSavePostClicked}
disabled={!canSave}
>Save Post</button>
</form>
</section>
)
}
export default AddPostForm

View File

@ -0,0 +1,119 @@
import { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { selectPostById, updatePost, deletePost } from './postsSlice'
import { useParams, useNavigate } from 'react-router-dom'
import { selectAllUsers } from "../users/usersSlice";
const EditPostForm = () => {
const { postId } = useParams()
const navigate = useNavigate()
const post = useSelector((state) => selectPostById(state, Number(postId)))
const users = useSelector(selectAllUsers)
const [title, setTitle] = useState(post?.title)
const [content, setContent] = useState(post?.body)
const [userId, setUserId] = useState(post?.userId)
const [requestStatus, setRequestStatus] = useState('idle')
const dispatch = useDispatch()
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
const onTitleChanged = e => setTitle(e.target.value)
const onContentChanged = e => setContent(e.target.value)
const onAuthorChanged = e => setUserId(e.target.value)
const canSave = [title, content, userId].every(Boolean) && requestStatus === 'idle';
const onSavePostClicked = () => {
if (canSave) {
try {
setRequestStatus('pending')
dispatch(updatePost({ id: post.id, title, body: content, userId, reactions: post.reactions })).unwrap()
setTitle('')
setContent('')
setUserId('')
navigate(`/post/${postId}`)
} catch (err) {
console.error('Failed to save the post', err)
} finally {
setRequestStatus('idle')
}
}
}
const usersOptions = users.map(user => (
<option
key={user.id}
value={user.id}
>{user.name}</option>
))
const onDeletePostClicked = () => {
try {
setRequestStatus('pending')
dispatch(deletePost({ id: post.id })).unwrap()
setTitle('')
setContent('')
setUserId('')
navigate('/')
} catch (err) {
console.error('Failed to delete the post', err)
} finally {
setRequestStatus('idle')
}
}
return (
<section>
<h2>Edit Post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" value={userId} onChange={onAuthorChanged}>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
<button
type="button"
onClick={onSavePostClicked}
disabled={!canSave}
>
Save Post
</button>
<button className="deleteButton"
type="button"
onClick={onDeletePostClicked}
>
Delete Post
</button>
</form>
</section>
)
}
export default EditPostForm

View File

@ -0,0 +1,11 @@
import { useSelector } from "react-redux";
import { selectAllUsers } from "../users/usersSlice";
const PostAuthor = ({ userId }) => {
const users = useSelector(selectAllUsers)
const author = users.find(user => user.id === userId);
return <span>by {author ? author.name : 'Unknown author'}</span>
}
export default PostAuthor

View File

@ -0,0 +1,20 @@
import PostAuthor from "./PostAuthor";
import TimeAgo from "./TimeAgo";
import ReactionButtons from "./ReactionButtons";
import { Link } from 'react-router-dom';
const PostsExcerpt = ({ post }) => {
return (
<article>
<h2>{post.title}</h2>
<p className="excerpt">{post.body.substring(0, 75)}...</p>
<p className="postCredit">
<Link to={`post/${post.id}`}>View Post</Link>
<PostAuthor userId={post.userId} />
<TimeAgo timestamp={post.date} />
</p>
<ReactionButtons post={post} />
</article>
)
}
export default PostsExcerpt

View File

@ -0,0 +1,27 @@
import { useSelector } from "react-redux";
import { selectAllPosts, getPostsStatus, getPostsError } from "./postsSlice";
import PostsExcerpt from "./PostsExcerpt";
const PostsList = () => {
const posts = useSelector(selectAllPosts);
const postStatus = useSelector(getPostsStatus);
const error = useSelector(getPostsError);
let content;
if (postStatus === 'loading') {
content = <p>"Loading..."</p>;
} else if (postStatus === 'succeeded') {
const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))
content = orderedPosts.map(post => <PostsExcerpt key={post.id} post={post} />)
} else if (postStatus === 'failed') {
content = <p>{error}</p>;
}
return (
<section>
{content}
</section>
)
}
export default PostsList

View File

@ -0,0 +1,32 @@
import { useDispatch } from "react-redux";
import { reactionAdded } from "./postsSlice";
const reactionEmoji = {
thumbsUp: '👍',
wow: '😮',
heart: '❤️',
rocket: '🚀',
coffee: '☕'
}
const ReactionButtons = ({ post }) => {
const dispatch = useDispatch()
const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
return (
<button
key={name}
type="button"
className="reactionButton"
onClick={() =>
dispatch(reactionAdded({ postId: post.id, reaction: name }))
}
>
{emoji} {post.reactions[name]}
</button>
)
})
return <div>{reactionButtons}</div>
}
export default ReactionButtons

View File

@ -0,0 +1,38 @@
import { useSelector } from 'react-redux'
import { selectPostById } from './postsSlice'
import PostAuthor from "./PostAuthor";
import TimeAgo from "./TimeAgo";
import ReactionButtons from "./ReactionButtons";
import { useParams } from 'react-router-dom';
import { Link } from 'react-router-dom';
const SinglePostPage = () => {
const { postId } = useParams()
const post = useSelector((state) => selectPostById(state, Number(postId)))
if (!post) {
return (
<section>
<h2>Post not found!</h2>
</section>
)
}
return (
<article>
<h2>{post.title}</h2>
<p>{post.body}</p>
<p className="postCredit">
<Link to={`/post/edit/${post.id}`}>Edit Post</Link>
<PostAuthor userId={post.userId} />
<TimeAgo timestamp={post.date} />
</p>
<ReactionButtons post={post} />
</article>
)
}
export default SinglePostPage

View File

@ -0,0 +1,17 @@
import { parseISO, formatDistanceToNow } from 'date-fns';
const TimeAgo = ({ timestamp }) => {
let timeAgo = ''
if (timestamp) {
const date = parseISO(timestamp)
const timePeriod = formatDistanceToNow(date)
timeAgo = `${timePeriod} ago`
}
return (
<span title={timestamp}>
&nbsp; <i>{timeAgo}</i>
</span>
)
}
export default TimeAgo

View File

@ -0,0 +1,162 @@
import { createSlice, nanoid, createAsyncThunk } from "@reduxjs/toolkit";
import { sub } from 'date-fns';
import axios from "axios";
const POSTS_URL = 'https://jsonplaceholder.typicode.com/posts';
const initialState = {
posts: [],
status: 'idle', //'idle' | 'loading' | 'succeeded' | 'failed'
error: null
}
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
try {
const response = await axios.get(POSTS_URL)
return [...response.data];
} catch (err) {
return err.message;
}
})
export const addNewPost = createAsyncThunk('posts/addNewPost', async (initialPost) => {
try {
const response = await axios.post(POSTS_URL, initialPost)
return response.data
} catch (err) {
return err.message;
}
})
export const updatePost = createAsyncThunk('posts/updatePost', async (initialPost) => {
const { id } = initialPost;
try {
const response = await axios.put(`${POSTS_URL}/${id}`, initialPost)
return response.data
} catch (err) {
//return err.message;
return initialPost; // only for testing Redux!
}
})
export const deletePost = createAsyncThunk('posts/deletePost', async (initialPost) => {
const { id } = initialPost;
try {
const response = await axios.delete(`${POSTS_URL}/${id}`)
if (response?.status === 200) return initialPost;
return `${response?.status}: ${response?.statusText}`;
} catch (err) {
return err.message;
}
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.posts.push(action.payload)
},
prepare(title, content, userId) {
return {
payload: {
id: nanoid(),
title,
content,
date: new Date().toISOString(),
userId,
reactions: {
thumbsUp: 0,
wow: 0,
heart: 0,
rocket: 0,
coffee: 0
}
}
}
}
},
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers(builder) {
builder
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Adding date and reactions
let min = 1;
const loadedPosts = action.payload.map(post => {
post.date = sub(new Date(), { minutes: min++ }).toISOString();
post.reactions = {
thumbsUp: 0,
wow: 0,
heart: 0,
rocket: 0,
coffee: 0
}
return post;
});
// Add any fetched posts to the array
state.posts = state.posts.concat(loadedPosts)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message
})
.addCase(addNewPost.fulfilled, (state, action) => {
action.payload.userId = Number(action.payload.userId)
action.payload.date = new Date().toISOString();
action.payload.reactions = {
thumbsUp: 0,
wow: 0,
heart: 0,
rocket: 0,
coffee: 0
}
console.log(action.payload)
state.posts.push(action.payload)
})
.addCase(updatePost.fulfilled, (state, action) => {
if (!action.payload?.id) {
console.log('Update could not complete')
console.log(action.payload)
return;
}
const { id } = action.payload;
action.payload.date = new Date().toISOString();
const posts = state.posts.filter(post => post.id !== id);
state.posts = [...posts, action.payload];
})
.addCase(deletePost.fulfilled, (state, action) => {
if (!action.payload?.id) {
console.log('Delete could not complete')
console.log(action.payload)
return;
}
const { id } = action.payload;
const posts = state.posts.filter(post => post.id !== id);
state.posts = posts;
})
}
})
export const selectAllPosts = (state) => state.posts.posts;
export const getPostsStatus = (state) => state.posts.status;
export const getPostsError = (state) => state.posts.error;
export const selectPostById = (state, postId) =>
state.posts.posts.find(post => post.id === postId);
export const { postAdded, reactionAdded } = postsSlice.actions
export default postsSlice.reducer

View File

@ -0,0 +1,30 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
const USERS_URL = 'https://jsonplaceholder.typicode.com/users';
const initialState = []
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
try {
const response = await axios.get(USERS_URL);
return [...response.data];
} catch (err) {
return err.message;
}
})
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload;
})
}
})
export const selectAllUsers = (state) => state.users;
export default usersSlice.reducer

View File

@ -0,0 +1,130 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: Cambria, Cochin, Georgia, Times, "Times New Roman", serif;
background-color: white;
color: #000;
}
body {
min-height: 100vh;
font-size: 1.5rem;
}
input,
textarea,
button,
select {
font: inherit;
margin-bottom: 1em;
}
header {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
background-color: purple;
color: whitesmoke;
position: sticky;
top: 0;
}
nav {
display: flex;
justify-content: flex-end;
}
nav ul {
list-style-type: none;
}
nav ul li {
display: inline-block;
margin-right: 1rem;
}
nav a, nav a:visited {
color: #fff;
text-decoration: none;
}
nav a:hover, nav a:focus {
text-decoration: underline;
}
main {
max-width: 500px;
margin: auto;
}
section {
margin-top: 1em;
}
article {
margin: 0.5em;
border: 1px solid #000;
border-radius: 10px;
padding: 1em;
}
h1 {
font-size: 3.5rem;
}
h2 {
margin-bottom: 1rem;
}
p {
font-family: Arial, Helvetica, sans-serif;
line-height: 1.4;
font-size: 1.2rem;
margin: 0.5em 0;
}
form {
display: flex;
flex-direction: column;
}
textarea {
height: 200px;
}
.postCredit {
font-size: 1rem;
}
.postCredit a,
.postCredit a:visited {
margin-right: 0.5rem;
color: black;
}
.postCredit a:hover,
.postCredit a:focus {
color: hsla(0, 0%, 0%, 0.75);
}
.excerpt {
font-style: italic;
}
.reactionButton {
margin: 0 0.25em 0 0;
background: transparent;
border: none;
color: #000;
font-size: 1rem;
}
.deleteButton {
background-color: palevioletred;
color: white;
}

View File

@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { store } from './app/store';
import { Provider } from 'react-redux';
import { fetchPosts } from './features/posts/postsSlice';
import { fetchUsers } from './features/users/usersSlice';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
store.dispatch(fetchPosts());
store.dispatch(fetchUsers());
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<Router>
<Routes>
<Route path="/*" element={<App />} />
</Routes>
</Router>
</Provider>
</React.StrictMode>,
document.getElementById('root')
);

23
04_lesson_starter/.gitignore vendored 100644
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

27268
04_lesson_starter/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
{
"name": "04_lesson",
"version": "0.1.0",
"private": true,
"dependencies": {
"@reduxjs/toolkit": "^1.8.0",
"axios": "^0.26.1",
"date-fns": "^2.28.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-redux": "^7.2.6",
"react-scripts": "5.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -0,0 +1,13 @@
import PostsList from "./features/posts/PostsList";
import AddPostForm from "./features/posts/AddPostForm";
function App() {
return (
<main className="App">
<AddPostForm />
<PostsList />
</main>
);
}
export default App;

View File

@ -0,0 +1,11 @@
import { configureStore } from "@reduxjs/toolkit";
import postsReducer from '../features/posts/postsSlice';
import usersReducer from '../features/users/usersSlice';
export const store = configureStore({
reducer: {
posts: postsReducer,
users: usersReducer
}
})

View File

@ -0,0 +1,81 @@
import { useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { addNewPost } from "./postsSlice";
import { selectAllUsers } from "../users/usersSlice";
const AddPostForm = () => {
const dispatch = useDispatch()
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [userId, setUserId] = useState('')
const [addRequestStatus, setAddRequestStatus] = useState('idle')
const users = useSelector(selectAllUsers)
const onTitleChanged = e => setTitle(e.target.value)
const onContentChanged = e => setContent(e.target.value)
const onAuthorChanged = e => setUserId(e.target.value)
const canSave = [title, content, userId].every(Boolean) && addRequestStatus === 'idle';
const onSavePostClicked = () => {
if (canSave) {
try {
setAddRequestStatus('pending')
dispatch(addNewPost({ title, body: content, userId })).unwrap()
setTitle('')
setContent('')
setUserId('')
} catch (err) {
console.error('Failed to save the post', err)
} finally {
setAddRequestStatus('idle')
}
}
}
const usersOptions = users.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))
return (
<section>
<h2>Add a New Post</h2>
<form>
<label htmlFor="postTitle">Post Title:</label>
<input
type="text"
id="postTitle"
name="postTitle"
value={title}
onChange={onTitleChanged}
/>
<label htmlFor="postAuthor">Author:</label>
<select id="postAuthor" value={userId} onChange={onAuthorChanged}>
<option value=""></option>
{usersOptions}
</select>
<label htmlFor="postContent">Content:</label>
<textarea
id="postContent"
name="postContent"
value={content}
onChange={onContentChanged}
/>
<button
type="button"
onClick={onSavePostClicked}
disabled={!canSave}
>Save Post</button>
</form>
</section>
)
}
export default AddPostForm

View File

@ -0,0 +1,11 @@
import { useSelector } from "react-redux";
import { selectAllUsers } from "../users/usersSlice";
const PostAuthor = ({ userId }) => {
const users = useSelector(selectAllUsers)
const author = users.find(user => user.id === userId);
return <span>by {author ? author.name : 'Unknown author'}</span>
}
export default PostAuthor

View File

@ -0,0 +1,18 @@
import PostAuthor from "./PostAuthor";
import TimeAgo from "./TimeAgo";
import ReactionButtons from "./ReactionButtons";
const PostsExcerpt = ({ post }) => {
return (
<article>
<h3>{post.title}</h3>
<p>{post.body.substring(0, 100)}</p>
<p className="postCredit">
<PostAuthor userId={post.userId} />
<TimeAgo timestamp={post.date} />
</p>
<ReactionButtons post={post} />
</article>
)
}
export default PostsExcerpt

View File

@ -0,0 +1,36 @@
import { useSelector, useDispatch } from "react-redux";
import { selectAllPosts, getPostsStatus, getPostsError, fetchPosts } from "./postsSlice";
import { useEffect } from "react";
import PostsExcerpt from "./PostsExcerpt";
const PostsList = () => {
const dispatch = useDispatch();
const posts = useSelector(selectAllPosts);
const postStatus = useSelector(getPostsStatus);
const error = useSelector(getPostsError);
useEffect(() => {
if (postStatus === 'idle') {
dispatch(fetchPosts())
}
}, [postStatus, dispatch])
let content;
if (postStatus === 'loading') {
content = <p>"Loading..."</p>;
} else if (postStatus === 'succeeded') {
const orderedPosts = posts.slice().sort((a, b) => b.date.localeCompare(a.date))
content = orderedPosts.map(post => <PostsExcerpt key={post.id} post={post} />)
} else if (postStatus === 'failed') {
content = <p>{error}</p>;
}
return (
<section>
<h2>Posts</h2>
{content}
</section>
)
}
export default PostsList

View File

@ -0,0 +1,32 @@
import { useDispatch } from "react-redux";
import { reactionAdded } from "./postsSlice";
const reactionEmoji = {
thumbsUp: '👍',
wow: '😮',
heart: '❤️',
rocket: '🚀',
coffee: '☕'
}
const ReactionButtons = ({ post }) => {
const dispatch = useDispatch()
const reactionButtons = Object.entries(reactionEmoji).map(([name, emoji]) => {
return (
<button
key={name}
type="button"
className="reactionButton"
onClick={() =>
dispatch(reactionAdded({ postId: post.id, reaction: name }))
}
>
{emoji} {post.reactions[name]}
</button>
)
})
return <div>{reactionButtons}</div>
}
export default ReactionButtons

View File

@ -0,0 +1,17 @@
import { parseISO, formatDistanceToNow } from 'date-fns';
const TimeAgo = ({ timestamp }) => {
let timeAgo = ''
if (timestamp) {
const date = parseISO(timestamp)
const timePeriod = formatDistanceToNow(date)
timeAgo = `${timePeriod} ago`
}
return (
<span title={timestamp}>
&nbsp; <i>{timeAgo}</i>
</span>
)
}
export default TimeAgo

View File

@ -0,0 +1,116 @@
import { createSlice, nanoid, createAsyncThunk } from "@reduxjs/toolkit";
import { sub } from 'date-fns';
import axios from "axios";
const POSTS_URL = 'https://jsonplaceholder.typicode.com/posts';
const initialState = {
posts: [],
status: 'idle', //'idle' | 'loading' | 'succeeded' | 'failed'
error: null
}
export const fetchPosts = createAsyncThunk('posts/fetchPosts', async () => {
try {
const response = await axios.get(POSTS_URL)
return [...response.data];
} catch (err) {
return err.message;
}
})
export const addNewPost = createAsyncThunk('posts/addNewPost', async (initialPost) => {
try {
const response = await axios.post(POSTS_URL, initialPost)
return response.data
} catch (err) {
return err.message;
}
})
const postsSlice = createSlice({
name: 'posts',
initialState,
reducers: {
postAdded: {
reducer(state, action) {
state.posts.push(action.payload)
},
prepare(title, content, userId) {
return {
payload: {
id: nanoid(),
title,
content,
date: new Date().toISOString(),
userId,
reactions: {
thumbsUp: 0,
wow: 0,
heart: 0,
rocket: 0,
coffee: 0
}
}
}
}
},
reactionAdded(state, action) {
const { postId, reaction } = action.payload
const existingPost = state.posts.find(post => post.id === postId)
if (existingPost) {
existingPost.reactions[reaction]++
}
}
},
extraReducers(builder) {
builder
.addCase(fetchPosts.pending, (state, action) => {
state.status = 'loading'
})
.addCase(fetchPosts.fulfilled, (state, action) => {
state.status = 'succeeded'
// Adding date and reactions
let min = 1;
const loadedPosts = action.payload.map(post => {
post.date = sub(new Date(), { minutes: min++ }).toISOString();
post.reactions = {
thumbsUp: 0,
wow: 0,
heart: 0,
rocket: 0,
coffee: 0
}
return post;
});
// Add any fetched posts to the array
state.posts = state.posts.concat(loadedPosts)
})
.addCase(fetchPosts.rejected, (state, action) => {
state.status = 'failed'
state.error = action.error.message
})
.addCase(addNewPost.fulfilled, (state, action) => {
action.payload.userId = Number(action.payload.userId)
action.payload.date = new Date().toISOString();
action.payload.reactions = {
thumbsUp: 0,
hooray: 0,
heart: 0,
rocket: 0,
eyes: 0
}
console.log(action.payload)
state.posts.push(action.payload)
})
}
})
export const selectAllPosts = (state) => state.posts.posts;
export const getPostsStatus = (state) => state.posts.status;
export const getPostsError = (state) => state.posts.error;
export const { postAdded, reactionAdded } = postsSlice.actions
export default postsSlice.reducer

View File

@ -0,0 +1,30 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import axios from "axios";
const USERS_URL = 'https://jsonplaceholder.typicode.com/users';
const initialState = []
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
try {
const response = await axios.get(USERS_URL);
return [...response.data];
} catch (err) {
return err.message;
}
})
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {},
extraReducers(builder) {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
return action.payload;
})
}
})
export const selectAllUsers = (state) => state.users;
export default usersSlice.reducer

View File

@ -0,0 +1,130 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: Cambria, Cochin, Georgia, Times, "Times New Roman", serif;
background-color: white;
color: #000;
}
body {
min-height: 100vh;
font-size: 1.5rem;
}
input,
textarea,
button,
select {
font: inherit;
margin-bottom: 1em;
}
header {
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
background-color: purple;
color: whitesmoke;
position: sticky;
top: 0;
}
nav {
display: flex;
justify-content: flex-end;
}
nav ul {
list-style-type: none;
}
nav ul li {
display: inline-block;
margin-right: 1rem;
}
nav a, nav a:visited {
color: #fff;
text-decoration: none;
}
nav a:hover, nav a:focus {
text-decoration: underline;
}
main {
max-width: 500px;
margin: auto;
}
section {
margin-top: 1em;
}
article {
margin: 0.5em;
border: 1px solid #000;
border-radius: 10px;
padding: 1em;
}
h1 {
font-size: 3.5rem;
}
h2 {
margin-bottom: 1rem;
}
p {
font-family: Arial, Helvetica, sans-serif;
line-height: 1.4;
font-size: 1.2rem;
margin: 0.5em 0;
}
form {
display: flex;
flex-direction: column;
}
textarea {
height: 200px;
}
.postCredit {
font-size: 1rem;
}
.postCredit a,
.postCredit a:visited {
margin-right: 0.5rem;
color: black;
}
.postCredit a:hover,
.postCredit a:focus {
color: hsla(0, 0%, 0%, 0.75);
}
.excerpt {
font-style: italic;
}
.reactionButton {
margin: 0 0.25em 0 0;
background: transparent;
border: none;
color: #000;
font-size: 1rem;
}
.deleteButton {
background-color: palevioletred;
color: white;
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { store } from './app/store';
import { Provider } from 'react-redux';
import { fetchUsers } from './features/users/usersSlice';
store.dispatch(fetchUsers());
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
);

View File

@ -43,6 +43,9 @@
- 🔗 [Redux Devtools](https://github.com/reduxjs/redux-devtools)
- 🔗 [Immer.js](https://immerjs.github.io/immer/)
### 📚 React Router References
- 🔗 [Official Site for React Router](https://reactrouter.com/docs/en/v6)
- 🔗 [React Router v6 Tutorial](https://github.com/gitdagray/react_router_v6)
### ⚙ VS Code Extensions I Use:
@ -60,5 +63,7 @@
- 🔗 [Chapter 2 Completed Code](https://github.com/gitdagray/react_redux_toolkit/tree/main/02_lesson)
- 🔗 [Chapter 3 Starter Code](https://github.com/gitdagray/react_redux_toolkit/tree/main/03_lesson_starter)
- 🔗 [Chapter 3 Completed Code](https://github.com/gitdagray/react_redux_toolkit/tree/main/03_lesson)
- 🔗 [Chapter 4 Starter Code](https://github.com/gitdagray/react_redux_toolkit/tree/main/04_lesson_starter)
- 🔗 [Chapter 4 Completed Code](https://github.com/gitdagray/react_redux_toolkit/tree/main/04_lesson)