├── .gitignore ├── LICENSE ├── README.md ├── config ├── .env ├── paths.js └── webpack │ ├── analyze.js │ ├── common.js │ ├── dev.js │ ├── prod.js │ └── pwa.js ├── package.json ├── public ├── assets │ ├── browserconfig.xml │ ├── icons │ │ ├── 128.png │ │ ├── 256.png │ │ ├── 512.png │ │ ├── 64.png │ │ └── logo.png │ ├── manifest.json │ ├── netlify.toml │ ├── robots.txt │ └── sitemap.xml ├── index.html └── sw-reg.js ├── src ├── App.js ├── assets │ └── logo.png ├── index.js └── modules │ ├── components │ ├── TodoControls.js │ ├── TodoFilters.js │ ├── TodoForm.js │ ├── TodoList │ │ ├── TodoListItemEdit.js │ │ ├── TodoListItemRegular.js │ │ └── index.js │ └── index.js │ ├── context │ ├── TodoContext.js │ └── package.json │ └── reducer │ ├── package.json │ └── todoProducer.js ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2021 Igor Agapov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Webpack 5 Max (JS/React/TS) 2 | 3 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 4 | 5 | ![logo](./public/assets/icons/logo.png) 6 | 7 | ### :zap: `Webpack 5 Boilerplate for JS/React/TS apps.` 8 | 9 | :link: [Demo on CodeSandbox](https://codesandbox.io/s/webpack5-max-jsreactts-j2o2u) 10 | 11 | --- 12 | 13 | ## Includes 14 | 15 | - 5 config files with all possible settings 16 | - common 17 | - common + development 18 | - common + production 19 | - common + production + pwa 20 | - common + production + analyze 21 | - React Todo App example 22 | - actions: add, complete, remove, edit, update 23 | - filters: all, active, completed 24 | - controls: mark all todos as completed, clear all completed todos 25 | - state management: useContext/useReducer 26 | - styling - react-bootstrap 27 | - type checking - prop-types 28 | - HTML template with all meta & link tags for SEO 29 | - don't forget to change values in HtmlWebpackPlugin templateParameters object 30 | - browserconfig.xml, robots.txt, sitemap.xml 31 | - service-worker & manifest.json 32 | - don't forget to change values in manifest.json 33 | - netlify.toml with all security headers 34 | --- 35 | You can easily add settings for Vue or Angular components. 36 | 37 | *Vue* 38 | 39 | - install deps 40 | 41 | ```bash 42 | yarn add -D vue-loader vue-template-compiler 43 | # or 44 | npm i -D yarn vue-loader vue-template-compiler 45 | ``` 46 | 47 | - add following to config/webpack/common.js 48 | 49 | ```js 50 | const VueLoaderPlugin = require('vue-loader/lib/plugin') 51 | 52 | module.exports = { 53 | module: { 54 | rules: [ 55 | { 56 | test: /\.vue$/, 57 | loader: 'vue-loader' 58 | } 59 | ] 60 | }, 61 | plugins: [ 62 | new VueLoaderPlugin() 63 | ] 64 | } 65 | ``` 66 | 67 | *Angular* 68 | 69 | - install dep 70 | 71 | ```bash 72 | yarn add -D angular2-template-loader 73 | # or 74 | npm i -D angular2-template-loader 75 | ``` 76 | 77 | - change following in config/webpack/common.js 78 | 79 | ```js 80 | { 81 | test: /.tsx?$/i, 82 | exclude: /node_modules/, 83 | use: [babelLoader, 'ts-loader', 'angular2-template-loader?keepUrl=true'] 84 | }, 85 | ``` 86 | 87 | --- 88 | 89 | ## Installation 90 | 91 | ```bash 92 | # clone repo 93 | git clone https://github.com/harryheman/Webpack5-Max.git 94 | 95 | # install deps 96 | yarn 97 | # or 98 | npm i 99 | ``` 100 | 101 | --- 102 | 103 | ## Usage 104 | 105 | ### Development Server 106 | 107 | ```bash 108 | yarn start 109 | # or 110 | npm start 111 | ``` 112 | 113 | ### Production Bundle 114 | 115 | ```bash 116 | yarn build 117 | # or 118 | npm run build 119 | ``` 120 | 121 | ### Production Bundle PWA 122 | 123 | ```bash 124 | yarn pwa 125 | # or 126 | npm run pwa 127 | ``` 128 | 129 | ### Production Bundle Analyzer 130 | 131 | ```bash 132 | yarn analyze 133 | # or 134 | npm run analyze 135 | ``` 136 | --- 137 | ## Author 138 | 139 | [Igor Agapov](https://github.com/harryheman) 140 | 141 | --- 142 | ## License 143 | 144 | This project is open source and available under the [MIT License](LICENSE) 145 | -------------------------------------------------------------------------------- /config/.env: -------------------------------------------------------------------------------- 1 | SECRET='Hello, world!' -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | public: path.resolve(__dirname, '../public'), 5 | src: path.resolve(__dirname, '../src'), 6 | build: path.resolve(__dirname, '../build') 7 | } 8 | -------------------------------------------------------------------------------- /config/webpack/analyze.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | 3 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer') 4 | .BundleAnalyzerPlugin 5 | 6 | const prod = require('./prod') 7 | 8 | module.exports = merge(prod, { 9 | plugins: [new BundleAnalyzerPlugin()] 10 | }) 11 | -------------------------------------------------------------------------------- /config/webpack/common.js: -------------------------------------------------------------------------------- 1 | const paths = require('../paths') 2 | 3 | const webpack = require('webpack') 4 | 5 | const CopyWebpackPlugin = require('copy-webpack-plugin') 6 | const HtmlWebpackPlugin = require('html-webpack-plugin') 7 | 8 | const Dotenv = require('dotenv-webpack') 9 | 10 | const babelLoader = { 11 | loader: 'babel-loader', 12 | options: { 13 | presets: ['@babel/preset-env', '@babel/preset-react'], 14 | plugins: [ 15 | '@babel/plugin-proposal-class-properties', 16 | '@babel/plugin-syntax-dynamic-import', 17 | '@babel/plugin-transform-runtime' 18 | ] 19 | } 20 | } 21 | 22 | module.exports = { 23 | entry: `${paths.src}/index.js`, 24 | output: { 25 | path: paths.build, 26 | filename: 'js/[name].bundle.js', 27 | publicPath: '/', 28 | clean: true, 29 | crossOriginLoading: 'anonymous', 30 | module: true, 31 | environment: { 32 | arrowFunction: true, 33 | bigIntLiteral: false, 34 | const: true, 35 | destructuring: true, 36 | dynamicImport: false, 37 | forOf: true 38 | } 39 | }, 40 | resolve: { 41 | alias: { 42 | '@': `${paths.src}/modules` 43 | }, 44 | extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx', '.json'] 45 | }, 46 | experiments: { 47 | topLevelAwait: true, 48 | outputModule: true 49 | }, 50 | module: { 51 | rules: [ 52 | // JavaScript, React 53 | { 54 | test: /\.m?jsx?$/i, 55 | exclude: /node_modules/, 56 | use: babelLoader 57 | }, 58 | // TypeScript 59 | { 60 | test: /.tsx?$/i, 61 | exclude: /node_modules/, 62 | use: [babelLoader, 'ts-loader'] 63 | }, 64 | // CSS, SASS 65 | { 66 | test: /\.(c|sa|sc)ss$/i, 67 | use: [ 68 | 'style-loader', 69 | { 70 | loader: 'css-loader', 71 | options: { importLoaders: 1 } 72 | }, 73 | 'sass-loader' 74 | ] 75 | }, 76 | // MD 77 | { 78 | test: /\.md$/i, 79 | use: ['html-loader', 'markdown-loader'] 80 | }, 81 | // static files 82 | { 83 | test: /\.(jpe?g|png|gif|svg|eot|ttf|woff2?)$/i, 84 | type: 'asset' 85 | } 86 | ] 87 | }, 88 | plugins: [ 89 | new webpack.ProgressPlugin(), 90 | 91 | new CopyWebpackPlugin({ 92 | patterns: [ 93 | { 94 | from: `${paths.public}/assets` 95 | } 96 | ] 97 | }), 98 | 99 | new HtmlWebpackPlugin({ 100 | template: `${paths.public}/index.html`, 101 | filename: 'index.html', 102 | templateParameters: { 103 | analytics: 'Google Analytics ID', 104 | author: 'Igor Agapov', 105 | publishedDate: '2021-02-27', 106 | description: 107 | 'Full Webpack 5 Boilerplate for JavaScript, React & TypeScript projects', 108 | keywords: 109 | 'webpack, webpack5, boilerplate, template, max, config, bundler, bundle, javascript, react, reactjs, react.js, typescript, project, app', 110 | title: 'Webpack5 Max', 111 | url: 'https://example.com' 112 | } 113 | }), 114 | 115 | new webpack.ProvidePlugin({ 116 | React: 'react' 117 | }), 118 | 119 | new Dotenv({ 120 | path: './config/.env' 121 | }) 122 | ] 123 | } 124 | -------------------------------------------------------------------------------- /config/webpack/dev.js: -------------------------------------------------------------------------------- 1 | const paths = require('../paths') 2 | 3 | const webpack = require('webpack') 4 | const { merge } = require('webpack-merge') 5 | 6 | const common = require('./common') 7 | 8 | module.exports = merge(common, { 9 | mode: 'development', 10 | devtool: 'eval-cheap-source-map', 11 | devServer: { 12 | compress: true, 13 | contentBase: paths.build, 14 | historyApiFallback: true, 15 | hot: true, 16 | open: true, 17 | port: 3000, 18 | clientLogLevel: 'silent' 19 | }, 20 | plugins: [new webpack.HotModuleReplacementPlugin()] 21 | }) 22 | -------------------------------------------------------------------------------- /config/webpack/prod.js: -------------------------------------------------------------------------------- 1 | const paths = require('../paths') 2 | const { merge } = require('webpack-merge') 3 | const common = require('./common') 4 | 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 6 | const ImageminPlugin = require('imagemin-webpack-plugin').default 7 | 8 | module.exports = merge(common, { 9 | mode: 'production', 10 | entry: { 11 | index: { 12 | import: `${paths.src}/index.js`, 13 | dependOn: ['react', 'helpers'] 14 | }, 15 | react: ['react', 'react-dom', 'prop-types'], 16 | helpers: ['immer', 'nanoid'] 17 | }, 18 | devtool: false, 19 | output: { 20 | filename: 'js/[name].[contenthash].bundle.js', 21 | publicPath: './' 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.(c|sa|sc)ss$/i, 27 | use: [ 28 | MiniCssExtractPlugin.loader, 29 | { 30 | loader: 'css-loader', 31 | options: { importLoaders: 1 } 32 | }, 33 | 'sass-loader' 34 | ] 35 | } 36 | ] 37 | }, 38 | plugins: [ 39 | new MiniCssExtractPlugin({ 40 | filename: 'css/[name].[contenthash].css', 41 | chunkFilename: '[id].css' 42 | }), 43 | 44 | new ImageminPlugin({ 45 | test: /\.(jpe?g|png|gif|svg)$/i 46 | }) 47 | ], 48 | optimization: { 49 | runtimeChunk: 'single' 50 | }, 51 | performance: { 52 | hints: 'warning', 53 | maxEntrypointSize: 512000, 54 | maxAssetSize: 512000 55 | } 56 | }) 57 | -------------------------------------------------------------------------------- /config/webpack/pwa.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge') 2 | const paths = require('../paths.js') 3 | 4 | const AddAssetHtmlPlugin = require('add-asset-html-webpack-plugin') 5 | const { GenerateSW } = require('workbox-webpack-plugin') 6 | 7 | const prod = require('./prod') 8 | 9 | module.exports = merge(prod, { 10 | plugins: [ 11 | new AddAssetHtmlPlugin({ filepath: `${paths.public}/sw-reg.js` }), 12 | new GenerateSW({ 13 | clientsClaim: true, 14 | skipWaiting: true 15 | }) 16 | ] 17 | }) 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack5-max", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "bootstrap": "^4.6.0", 9 | "immer": "^8.0.1", 10 | "nanoid": "^3.1.20", 11 | "prop-types": "^15.7.2", 12 | "react": "^17.0.1", 13 | "react-bootstrap": "^1.5.0", 14 | "react-dom": "^17.0.1", 15 | "react-icons": "^4.2.0" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.12.13", 19 | "@babel/plugin-proposal-class-properties": "^7.12.13", 20 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 21 | "@babel/plugin-transform-runtime": "^7.13.15", 22 | "@babel/preset-env": "^7.12.13", 23 | "@babel/preset-react": "^7.12.13", 24 | "@tsconfig/recommended": "^1.0.1", 25 | "add-asset-html-webpack-plugin": "^3.2.0", 26 | "babel-loader": "^8.2.2", 27 | "copy-webpack-plugin": "^7.0.0", 28 | "css-loader": "^5.0.1", 29 | "dotenv-webpack": "^6.0.0", 30 | "html-loader": "^2.0.0", 31 | "html-webpack-plugin": "^5.0.0", 32 | "imagemin-webpack-plugin": "^2.4.2", 33 | "markdown-loader": "^6.0.0", 34 | "mini-css-extract-plugin": "^1.3.5", 35 | "sass": "^1.32.7", 36 | "sass-loader": "^11.0.1", 37 | "style-loader": "^2.0.0", 38 | "webpack": "^5.21.1", 39 | "webpack-bundle-analyzer": "^4.4.0", 40 | "webpack-cli": "^4.5.0", 41 | "webpack-dev-server": "^3.11.2", 42 | "webpack-merge": "^5.7.3", 43 | "workbox-webpack-plugin": "^6.1.0" 44 | }, 45 | "scripts": { 46 | "start": "webpack serve -c config/webpack/dev.js", 47 | "build": "webpack -c config/webpack/prod.js", 48 | "pwa": "webpack -c config/webpack/pwa.js", 49 | "analyze": "webpack --analyze -c config/webpack/analyze.js" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #f0f0f0 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/assets/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryheman/webpack5-max/7ba1bd8108e4022d75a7bd5ca07fc9924cef13a5/public/assets/icons/128.png -------------------------------------------------------------------------------- /public/assets/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryheman/webpack5-max/7ba1bd8108e4022d75a7bd5ca07fc9924cef13a5/public/assets/icons/256.png -------------------------------------------------------------------------------- /public/assets/icons/512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryheman/webpack5-max/7ba1bd8108e4022d75a7bd5ca07fc9924cef13a5/public/assets/icons/512.png -------------------------------------------------------------------------------- /public/assets/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryheman/webpack5-max/7ba1bd8108e4022d75a7bd5ca07fc9924cef13a5/public/assets/icons/64.png -------------------------------------------------------------------------------- /public/assets/icons/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryheman/webpack5-max/7ba1bd8108e4022d75a7bd5ca07fc9924cef13a5/public/assets/icons/logo.png -------------------------------------------------------------------------------- /public/assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Webpack/React/TypeScript", 3 | "short_name": "Webpack", 4 | "scope": "/", 5 | "start_url": "./", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "background_color": "#f0f0f0", 9 | "theme_color": "#1c1c1c", 10 | "description": "Webpack 5 Boilerplate for JavaScript, React & TypeScript projects", 11 | "icons": [ 12 | { 13 | "src": "./icons/64.png", 14 | "sizes": "64x64", 15 | "type": "image/png" 16 | }, 17 | { 18 | "src": "./icons/128.png", 19 | "sizes": "128x128", 20 | "type": "image/png" 21 | }, 22 | { 23 | "src": "./icons/256.png", 24 | "sizes": "256x256", 25 | "type": "image/png", 26 | "purpose": "any maskable" 27 | }, 28 | { 29 | "src": "./icons/512.png", 30 | "sizes": "512x512", 31 | "type": "image/png" 32 | } 33 | ], 34 | "serviceworker": { 35 | "src": "../service-worker.js" 36 | } 37 | } -------------------------------------------------------------------------------- /public/assets/netlify.toml: -------------------------------------------------------------------------------- 1 | [[headers]] 2 | for = "/*" 3 | [headers.values] 4 | X-Frame-Options = "sameorigin" 5 | X-Content-Type-Options = "nosniff" 6 | Content-Security-Policy = "frame-ancestors 'self'" 7 | X-XSS-Protection = "1; mode=block" 8 | Referrer-Policy = "strict-origin" 9 | Permissions-Policy = "camera=(), geolocation=(), microphone=()" 10 | Cache-Control = "max-age=31536000" -------------------------------------------------------------------------------- /public/assets/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /public/assets/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://example.com 5 | 2021-02-27 6 | monthly 7 | 1.0 8 | 9 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | <%= title %> 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 61 | 70 | 71 | 97 | 98 | 99 | 100 |
101 | 102 | 103 | -------------------------------------------------------------------------------- /public/sw-reg.js: -------------------------------------------------------------------------------- 1 | navigator.serviceWorker 2 | .register('./service-worker.js') 3 | .then((registration) => { 4 | console.log('SW registered: ', registration) 5 | }) 6 | .catch((registrationError) => { 7 | console.log('SW registration failed: ', registrationError) 8 | }) 9 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import { TodoProvider } from '@/context' 2 | // styles 3 | import { Container } from 'react-bootstrap' 4 | // components 5 | import { TodoForm, TodoList, TodoFilters, TodoControls } from '@/components' 6 | 7 | export const App = () => ( 8 | 9 | 10 |

React Todo App

11 | 12 | 13 | 14 | 15 |
16 |
17 | ) 18 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harryheman/webpack5-max/7ba1bd8108e4022d75a7bd5ca07fc9924cef13a5/src/assets/logo.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { StrictMode } from 'react' 2 | import { render } from 'react-dom' 3 | // components 4 | import { App } from './App' 5 | // styles 6 | import 'bootstrap/dist/css/bootstrap.min.css' 7 | 8 | console.log(process.env.SECRET) 9 | 10 | import logo from './assets/logo.png' 11 | 12 | const imgStyles = { 13 | width: '100px', 14 | display: 'block', 15 | margin: '0.5rem auto 0' 16 | } 17 | 18 | const rootEl = document.getElementById('root') 19 | render( 20 | 21 | # 22 | 23 | , 24 | rootEl 25 | ) 26 | -------------------------------------------------------------------------------- /src/modules/components/TodoControls.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | // styles 3 | import { Container, ButtonGroup, Button } from 'react-bootstrap' 4 | 5 | import { TodoContext } from '@/context' 6 | 7 | const CONTROLS = { 8 | mark: 'markAllCompleted', 9 | clear: 'clearCompleted' 10 | } 11 | 12 | export function TodoControls() { 13 | const [, dispatch] = useContext(TodoContext) 14 | 15 | const onControlClick = ({ target: { name } }) => { 16 | dispatch({ type: `control/${CONTROLS[name]}` }) 17 | } 18 | 19 | return ( 20 | 21 |

Controls

22 | 23 | 30 | 37 | 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /src/modules/components/TodoFilters.js: -------------------------------------------------------------------------------- 1 | import { useContext, useRef, useEffect } from 'react' 2 | // styles 3 | import { Container, Form } from 'react-bootstrap' 4 | 5 | import { TodoContext } from '@/context' 6 | 7 | export function TodoFilters() { 8 | const [, dispatch] = useContext(TodoContext) 9 | const radioRef = useRef(null) 10 | 11 | useEffect(() => { 12 | radioRef.current.checked = true 13 | }, []) 14 | 15 | const handleFilterChange = ({ target: { id } }) => { 16 | dispatch({ type: `filter/${id}` }) 17 | } 18 | 19 | return ( 20 | 21 |

Filters

22 | 31 | 39 | 47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /src/modules/components/TodoForm.js: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react' 2 | import { nanoid } from 'nanoid' 3 | // styles 4 | import { Container, Form, Button } from 'react-bootstrap' 5 | 6 | import { TodoContext } from '@/context' 7 | 8 | export function TodoForm() { 9 | const [text, setText] = useState('') 10 | const [, dispatch] = useContext(TodoContext) 11 | 12 | const handleChange = (e) => { 13 | setText(e.target.value) 14 | } 15 | 16 | const handleSubmit = (e) => { 17 | e.preventDefault() 18 | 19 | if (!text.trim()) return 20 | 21 | const newTodo = { 22 | id: nanoid(8), 23 | text, 24 | completed: false, 25 | edit: false, 26 | show: true 27 | } 28 | 29 | dispatch({ type: 'todo/add', payload: newTodo }) 30 | 31 | setText('') 32 | } 33 | 34 | return ( 35 | 36 |

Enter todo text

37 |
38 | 44 | 47 | 48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /src/modules/components/TodoList/TodoListItemEdit.js: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | // styles 4 | import { Form, ButtonGroup, Button } from 'react-bootstrap' 5 | // icons 6 | import { BsCheckCircle } from 'react-icons/bs' 7 | import { FcCancel } from 'react-icons/fc' 8 | 9 | export const TodoListItemEdit = ({ id, dispatch, handleEdit }) => { 10 | const updateRef = useRef(null) 11 | 12 | useEffect(() => { 13 | updateRef.current.focus() 14 | }, [id]) 15 | 16 | const handleUpdate = (id) => { 17 | const text = updateRef.current.value.trim() 18 | 19 | if (text) 20 | dispatch({ 21 | type: 'todo/update', 22 | payload: { id, text } 23 | }) 24 | 25 | handleEdit(id) 26 | } 27 | 28 | const handleKeyDown = (e) => { 29 | if (e.which === 13) { 30 | handleUpdate(e.target.dataset.id) 31 | } 32 | } 33 | 34 | useEffect(() => { 35 | window.addEventListener('keydown', handleKeyDown) 36 | 37 | return () => { 38 | window.removeEventListener('keydown', handleKeyDown) 39 | } 40 | // eslint-disable-next-line 41 | }, []) 42 | 43 | return ( 44 | <> 45 | 46 | 47 | 54 | 61 | 62 | 63 | ) 64 | } 65 | 66 | TodoListItemEdit.propTypes = { 67 | id: PropTypes.string.isRequired, 68 | dispatch: PropTypes.func.isRequired, 69 | handleEdit: PropTypes.func.isRequired 70 | } 71 | -------------------------------------------------------------------------------- /src/modules/components/TodoList/TodoListItemRegular.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | // styles 3 | import { Form, ButtonGroup, Button } from 'react-bootstrap' 4 | // icons 5 | import { FiEdit2 } from 'react-icons/fi' 6 | import { AiOutlineDelete } from 'react-icons/ai' 7 | 8 | export const TodoListItemRegular = ({ 9 | id, 10 | text, 11 | completed, 12 | dispatch, 13 | handleEdit 14 | }) => { 15 | const handleComplete = (id) => { 16 | dispatch({ type: 'todo/complete', payload: id }) 17 | } 18 | 19 | const handleRemove = (id) => { 20 | dispatch({ type: 'todo/remove', payload: id }) 21 | } 22 | 23 | return ( 24 | <> 25 | handleComplete(id)} /> 26 | 33 | {text} 34 | 35 | 36 | 44 | 51 | 52 | 53 | ) 54 | } 55 | 56 | TodoListItemRegular.propTypes = { 57 | id: PropTypes.string.isRequired, 58 | text: PropTypes.string.isRequired, 59 | completed: PropTypes.bool.isRequired, 60 | dispatch: PropTypes.func.isRequired, 61 | handleEdit: PropTypes.func.isRequired 62 | } 63 | -------------------------------------------------------------------------------- /src/modules/components/TodoList/index.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useContext } from 'react' 2 | // styles 3 | import { Container, ListGroup } from 'react-bootstrap' 4 | 5 | import { TodoContext } from '@/context' 6 | // components 7 | import { TodoListItemRegular } from './TodoListItemRegular' 8 | import { TodoListItemEdit } from './TodoListItemEdit' 9 | 10 | export function TodoList() { 11 | const [count, setCount] = useState(0) 12 | const [state, dispatch] = useContext(TodoContext) 13 | 14 | useEffect(() => { 15 | const activeTodos = state.todos.filter((todo) => !todo.completed) 16 | setCount(activeTodos.length) 17 | }, [state]) 18 | 19 | const handleEdit = (id) => { 20 | dispatch({ type: 'todo/edit', payload: id }) 21 | } 22 | 23 | return ( 24 | 25 |

{count ? `Todos left: ${count}` : 'No todos left'}

26 | 27 | {state.todos.map( 28 | (todo, index) => 29 | todo.show && ( 30 | 35 | {!todo.edit ? ( 36 | 41 | ) : ( 42 | 47 | )} 48 | 49 | ) 50 | )} 51 | 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/modules/components/index.js: -------------------------------------------------------------------------------- 1 | export { TodoForm } from './TodoForm' 2 | export { TodoList } from './TodoList' 3 | export { TodoFilters } from './TodoFilters' 4 | export { TodoControls } from './TodoControls' 5 | -------------------------------------------------------------------------------- /src/modules/context/TodoContext.js: -------------------------------------------------------------------------------- 1 | import { createContext, useReducer } from 'react' 2 | // reducer 3 | import { todoProducer } from '@/reducer' 4 | 5 | const initialState = { 6 | todos: [ 7 | { id: '1', text: 'Eat', completed: true, edit: false, show: true }, 8 | { id: '2', text: 'Sleep', completed: false, edit: false, show: true }, 9 | { id: '3', text: 'Repeat', completed: false, edit: false, show: true } 10 | ] 11 | } 12 | 13 | export const TodoContext = createContext(null) 14 | 15 | export const TodoProvider = ({ children }) => { 16 | const [state, dispatch] = useReducer(todoProducer, initialState) 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/modules/context/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./TodoContext" 3 | } -------------------------------------------------------------------------------- /src/modules/reducer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "./todoProducer" 3 | } -------------------------------------------------------------------------------- /src/modules/reducer/todoProducer.js: -------------------------------------------------------------------------------- 1 | import produce from 'immer' 2 | 3 | export const todoProducer = produce((draft, action) => { 4 | const { type, payload } = action 5 | const { todos } = draft 6 | 7 | // helper 8 | const findTodo = (id, index = false) => { 9 | const fn = (i) => i.id === id 10 | return !index ? todos.find(fn) : todos.findIndex(fn) 11 | } 12 | 13 | switch (type) { 14 | case 'todo/add': { 15 | todos.unshift(payload) 16 | break 17 | } 18 | case 'todo/complete': { 19 | const todo = findTodo(payload) 20 | todo.completed = !todo.completed 21 | break 22 | } 23 | case 'todo/remove': { 24 | const index = findTodo(payload, true) 25 | todos.splice(index, 1) 26 | break 27 | } 28 | case 'todo/edit': { 29 | const todo = findTodo(payload) 30 | todo.edit = !todo.edit 31 | break 32 | } 33 | case 'todo/update': { 34 | const { id, text } = payload 35 | const todo = findTodo(id) 36 | todo.text = text 37 | break 38 | } 39 | case 'filter/all': { 40 | todos.forEach((todo) => (todo.show = true)) 41 | break 42 | } 43 | case 'filter/active': { 44 | todos.forEach((todo) => 45 | todo.completed ? (todo.show = false) : (todo.show = true) 46 | ) 47 | break 48 | } 49 | case 'filter/completed': { 50 | todos.forEach((todo) => 51 | todo.completed ? (todo.show = true) : (todo.show = false) 52 | ) 53 | break 54 | } 55 | case 'control/markAllCompleted': { 56 | todos.forEach((todo) => (todo.completed = true)) 57 | break 58 | } 59 | case 'control/clearCompleted': { 60 | return { ...draft, todos: todos.filter((todo) => !todo.completed) } 61 | } 62 | default: 63 | return draft 64 | } 65 | }) 66 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/recommended/tsconfig.json" 3 | } --------------------------------------------------------------------------------