├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── manifest.json └── robots.txt └── src ├── components ├── Chart.jsx ├── Charts.jsx ├── Navbar.jsx └── Navbar.test.js ├── index.js ├── setupTests.js └── styles.scss /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Module Project: Composing Stateful Logic - Dark Mode 2 | 3 | This project allows you to practice the concepts and techniques learned in this module and apply them in a concrete project. This module explored Form management in React. You learned what stateful logic is, how to build custom hooks, how to compose multiple hooks together, and how to use mocks and spies in testing. In your project you will demonstrate proficiency of these subjects and principles by creating an application using each of these. 4 | 5 | ## Instructions 6 | 7 | **Read these instructions carefully. Understand exactly what is expected _before_ starting this project.** 8 | 9 | ### Commits 10 | 11 | Commit your code regularly and meaningfully. This helps you and any collaborators in case you ever need to return to old code for any number of reasons. 12 | 13 | ### Description 14 | 15 | In this project you'll take this crypto currency tracker app and build two custom hooks that, when composed together, will allow users to set and persist a dark mode preference. 16 | 17 | ## Project Set Up 18 | 19 | - [ ] Create a forked copy of this project. 20 | - [ ] Clone your OWN version of the repository in your terminal 21 | - [ ] CD into the project base directory `cd dark-mode` 22 | - [ ] Download project dependencies by running `npm install` 23 | - [ ] Start up the app using `npm start` 24 | - [ ] Create a new branch: git checkout -b ``. 25 | - [ ] Implement the project on your newly created `` branch, committing changes regularly. 26 | - [ ] Push commits: git push origin ``. 27 | 28 | Follow these steps for completing your project. 29 | 30 | - [ ] Submit a Pull-Request to merge Branch into main (student's Repository). **Please don't merge your own pull request** 31 | - [ ] From the home page of your repo, make sure you have your branch selected 32 | - [ ] Copy the URL and paste it into Canvas 33 | 34 | ## Minimum Viable Product 35 | 36 | - [ ] Build a custom hook that let's you save data to localStorage 37 | - [ ] Build a second custom hook that sets the `dark-mode` class on the body element 38 | - [ ] Compose your two new hooks together to be able to set and persist your user's dark mode preference in your app 39 | 40 | ## STEP 1 - useLocalStorage 41 | 42 | Open your app and take a look around. The crypto currency data is being fetched and displayed for you. In `styles.scss`, at the very bottom, you'll notice there are some styles for a class called `dark-mode`. Soon, we'll write a custom hook that sets this class on the body tag. That hook is going to compose a `useLocalStorage` inside it to accomplish that, so let's write the localStorage one first. 43 | 44 | This is going to be a pretty cool hook. It will be used pretty much the same way as `useState`, but with a key and value passed into it - ie `const [name, setName] = useLocalStorage('name', 'Dustin')`. You can use `setName` to update the value of `name` on localStorage! Pretty cool, huh? Let's get to it! 45 | 46 | - Create a new directory called `hooks`, and a new file in it called `useLocalStorage`. 47 | - Build a function called `useLocalStorage`. Now, to set something to localStorage, we need a key (must be a string) and a value (can be anything). To retrieve something from localStorage, we need the key. To update something in localStorage, you use the same method as adding something new, and it will just replace the old key/value pair in localStorage. Knowing this, let's add `key` and `initialValue` as parameters to the hook. 48 | - We're going to set up some state here. Set up a state property called storedValue. 49 | - This state property is going to take a function as it's initial value. When we do this, whatever that callback function returns is what gets set as the intialValue for the state property. 50 | - In the callback function, we'll check to see if the item we passed in already exists in localStorage, and return that value, otherwise we'll return whatever initialValue was passed in. 51 | - Quick note, if you pass in arrays or objects to localStorage, you will need to parse it into JSON. Then when you retrieve it, like we do below, you'll need to parse it back into regular JavaScript 52 | 53 | ```js 54 | // To retrieve an item from localStorage, call localStorage.getItem('itemName') 55 | // If that item doesn't exist, it will return undefined 56 | const [storedValue, setStoredValue] = useState(() => { 57 | // Get from local storage by key 58 | const item = window.localStorage.getItem(key); 59 | // Parse and return stored json or, if undefined, return initialValue 60 | return item ? JSON.parse(item) : initialValue; 61 | }); 62 | ``` 63 | 64 | - Now, let's return `storedValue` from this hook in an array: 65 | 66 | ```js 67 | import { useState } from "react"; 68 | 69 | export const useLocalStorage = (key, initialValue) => { 70 | const [storedValue, setStoredValue] = useState(() => { 71 | const item = window.localStorage.getItem(key); 72 | return item ? JSON.parse(item) : initialValue; 73 | }); 74 | 75 | return [storedValue]; 76 | }; 77 | ``` 78 | 79 | - Remember we're trying to use this hook like this: `const [name, setName] = useLocalStorage('name', 'Dustin')`. So far we have made the value part of the hook, but not the setter. Let's go ahead and create a setter function, and return that in the array as well. 80 | - inside the hook, write a function called `setValue` that takes a `value` parameter 81 | - In `setValue`, set the `value` to localStorage using the `key` that was passed into the hook itself 82 | - Also, update the state of `storedValue` with `value` as well 83 | - Add `setValue` to the array that is being returned out of this hook 84 | - `setValue` should look something like this: 85 | 86 | ```js 87 | const setValue = value => { 88 | // Save state 89 | setStoredValue(value); 90 | // Save to local storage 91 | window.localStorage.setItem(key, JSON.stringify(value)); 92 | }; 93 | ``` 94 | 95 | We're going to use this inside our dark mode hook, but this can be used anywhere for any kind of localStorage needs you have in your apps. Custom hooks are so awesome!! 96 | 97 | ## STEP 2 - useDarkMode 98 | 99 | - Inside the `hooks` directory, add a new file called `useDarkMode`. 100 | - Build a function called `useDarkMode`. 101 | - Import `useLocalStorage` 102 | - Call `useLocalStorage` and pass in the key you want to use to store to indicate whether or not dark mode is enabled. Remember, this hook returns an array with a value and a setter in an array, exactly like the state hook, so make sure to capture those values in a `const` - `const [someValue, setSomeValue] = useLocalStorage('your key here')` 103 | - Finally, we need to return something out of `useDarkMode`, so we can use this in our app. What do you think we'll need? We'll need to know if dark mode is enabled, right? And we'll need a setter function to toggle dark mode. Let's just forward the value and the setter that were returned out of the `useLocalStorage` call. Return those two values in an array as well. 104 | 105 | _In this case `useDarkMode` isn't doing any of it's own logic, just simply composing `useLocalStorage` inside it and passing those values back to the component. There are other things we **could** do here to extend even more logic. If you want to try that after you're finished, check out the first stretch goal 👍_ 106 | 107 | ## STEP 3 - Using the hook in a component 108 | 109 | Now that we have composed our different pieces of stateful logic, let's use it in our component! 110 | 111 | - import the dark mode hook into the `App` component 112 | - Looking at this component, we see that we are controlling the toggle with some state. The state hook here returns a `darkMode` value, and a `setDarkMode` function. Isn't that exactly what our `useDarkMode` hook returns as well? Replace the state hook with our hook, click the toggle, and watch the magic happen!!! 113 | 114 | (If it wasn't magical, you have a bug somewhere 😫 go back through the steps slowly, one at a time, to see if you missed any of the steps) 115 | 116 | ## Stretch Problems 117 | 118 | After finishing your required elements, you can push your work further. These goals may or may not be things you have learned in this module but they build on the material you just studied. Time allowing, stretch your limits and see if you can deliver on the following optional goals: 119 | 120 | - Look at [this implementation](https://usehooks.com/useDarkMode/) of a `useDarkMode` hook that has more logic built into it (ignore the `useEffect` hook which has some direct DOM manipulation). In your `useDarkMode` hook, build in the `usePrefersDarkMode` logic that will check to see what you have set your OS theme preference to and apply that to your site. 121 | 122 | - Add routing into this app and build out some other pages 123 | 124 | - Go to the [Coin Gecko API](https://www.coingecko.com/) where we got this data from, and add more features to your app. Maybe you want to make a dropdown list of coins, and only look at one coin at a time. You could make an API call to that API for a specific coin and get more data on it. You could get more specific coin data for the last 24 hrs. There's a lot you can do with this API. Explore and have fun! 125 | 126 | - Look into the recharts library and build a new chart. Or change the appearence of the charts we built out here. Maybe when you toggle to dark mode, the line on the chart could change colors! There's a lot you can do with this library. Explore and have fun! 127 | 128 | ## Submission Format 129 | 130 | - [ ] Submit a Pull-Request to merge Branch into `main` (student's Repository). **Please don't merge your own pull request** 131 | - [ ] From the home page of your repo, make sure you have your branch selected 132 | - [ ] Copy the URL and paste it into Canvas to submit your project 133 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dark-mode", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.4.1", 8 | "@testing-library/user-event": "^7.2.1", 9 | "axios": "^0.19.2", 10 | "moment": "^2.24.0", 11 | "node-sass": "^4.13.1", 12 | "react": "^16.13.0", 13 | "react-dom": "^16.13.0", 14 | "react-scripts": "3.4.0", 15 | "recharts": "^2.0.0-beta.1" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bloominstituteoftechnology/dark-mode/e2a986ae1e37f24a4291a3824bd1bfe78225a87d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 20 | 24 | Dark Mode 25 | 26 | 27 | 28 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /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 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/components/Chart.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import moment from "moment"; 3 | import { 4 | LineChart, 5 | Line, 6 | CartesianGrid, 7 | XAxis, 8 | YAxis, 9 | Tooltip 10 | } from "recharts"; 11 | 12 | const Chart = ({ sparklineData }) => { 13 | const formattedData = sparklineData 14 | .map((price, idx) => { 15 | if (idx % 6 === 0) { 16 | const timeToSubtract = 168 - idx; 17 | const date = moment() 18 | .subtract(timeToSubtract, "hours") 19 | .format("ddd h:mma"); 20 | return { value: price, date }; 21 | } else if (idx === sparklineData.length - 1) { 22 | const date = moment().format("ddd h:mma"); 23 | return { value: price, date }; 24 | } 25 | return null; 26 | }) 27 | .filter(data => data); 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | export default Chart; 41 | -------------------------------------------------------------------------------- /src/components/Charts.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Chart from "./Chart"; 3 | 4 | const Charts = ({ coinData }) => { 5 | return ( 6 |
7 | {coinData.map(coin => ( 8 |
9 |

{coin.name}

10 |

{coin.symbol}

11 |
12 | {coin.name} 13 |
14 | 15 |
16 | ))} 17 |
18 | ); 19 | }; 20 | export default Charts; 21 | -------------------------------------------------------------------------------- /src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | const Navbar = (props) => { 4 | const toggleMode = e => { 5 | e.preventDefault(); 6 | props.setDarkMode(!props.darkMode); 7 | }; 8 | return ( 9 | 18 | ); 19 | }; 20 | 21 | export default Navbar; 22 | -------------------------------------------------------------------------------- /src/components/Navbar.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as rtl from '@testing-library/react'; 3 | import Navbar from './Navbar'; 4 | 5 | test('renders Navbar without crashing', () => { 6 | rtl.render(); 7 | }); 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import axios from "axios"; 4 | 5 | import Charts from "./components/Charts"; 6 | import Navbar from "./components/Navbar"; 7 | 8 | import "./styles.scss"; 9 | 10 | const App = () => { 11 | const [coinData, setCoinData] = useState([]); 12 | const [darkMode, setDarkMode] = useState(false); 13 | 14 | useEffect(() => { 15 | axios 16 | .get( 17 | "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=10&page=1&sparkline=true" 18 | ) 19 | .then(res => setCoinData(res.data)) 20 | .catch(err => console.log(err)); 21 | }, []); 22 | return ( 23 |
24 | 25 | 26 |
27 | ); 28 | }; 29 | 30 | const rootElement = document.getElementById("root"); 31 | ReactDOM.render(, rootElement); 32 | -------------------------------------------------------------------------------- /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/styles.scss: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | border: 0; 90 | font-size: 100%; 91 | font: inherit; 92 | vertical-align: baseline; 93 | } 94 | /* HTML5 display-role reset for older browsers */ 95 | article, 96 | aside, 97 | details, 98 | figcaption, 99 | figure, 100 | footer, 101 | header, 102 | hgroup, 103 | menu, 104 | nav, 105 | section { 106 | display: block; 107 | } 108 | body { 109 | line-height: 1; 110 | } 111 | ol, 112 | ul { 113 | list-style: none; 114 | } 115 | blockquote, 116 | q { 117 | quotes: none; 118 | } 119 | blockquote:before, 120 | blockquote:after, 121 | q:before, 122 | q:after { 123 | content: ''; 124 | content: none; 125 | } 126 | table { 127 | border-collapse: collapse; 128 | border-spacing: 0; 129 | } 130 | 131 | /* General Styles */ 132 | 133 | * { 134 | box-sizing: border-box; 135 | } 136 | 137 | html { 138 | font-size: 62.5%; 139 | font-family: 'Open Sans', sans-serif; 140 | font-weight: 500; 141 | } 142 | 143 | h1 { 144 | font-size: 2.4rem; 145 | } 146 | 147 | h2 { 148 | font-size: 1.8rem; 149 | } 150 | 151 | h4 { 152 | font-size: 1.4rem; 153 | } 154 | 155 | img { 156 | height: auto; 157 | width: 100%; 158 | } 159 | 160 | .flex-spacer { 161 | width: 100%; 162 | } 163 | 164 | .App { 165 | font-family: sans-serif; 166 | text-align: center; 167 | width: 100%; 168 | } 169 | 170 | .navbar { 171 | align-items: center; 172 | border-bottom: 1px solid rgb(221, 221, 221); 173 | display: flex; 174 | height: 70px; 175 | justify-content: space-between; 176 | padding: 0 3%; 177 | width: 100%; 178 | -webkit-box-shadow: 0px 2px 15px -8px rgba(0, 0, 0, 0.42); 179 | -moz-box-shadow: 0px 2px 15px -8px rgba(0, 0, 0, 0.42); 180 | box-shadow: 0px 2px 15px -8px rgba(0, 0, 0, 0.42); 181 | } 182 | 183 | .dark-mode__toggle { 184 | background: papayawhip; 185 | border-radius: 50px; 186 | border: 1px solid black; 187 | height: 20px; 188 | position: relative; 189 | width: 40px; 190 | } 191 | 192 | .toggle { 193 | background: #f68819; 194 | border-radius: 50px; 195 | height: 18px; 196 | left: 0; 197 | position: absolute; 198 | transition: 0.2s; 199 | width: 20px; 200 | } 201 | 202 | .toggled { 203 | left: 18px; 204 | } 205 | 206 | .charts { 207 | width: 80%; 208 | margin: 0 auto; 209 | } 210 | 211 | .chart__container { 212 | align-items: center; 213 | display: flex; 214 | flex-direction: column; 215 | justify-content: center; 216 | margin: 50px 0 0; 217 | width: 100%; 218 | } 219 | 220 | .chart-header { 221 | display: flex; 222 | align-items: center; 223 | justify-content: space-between; 224 | width: 100%; 225 | max-width: 900px; 226 | margin-bottom: 16px; 227 | } 228 | 229 | .coin__title { 230 | display: block; 231 | margin-left: 8px; 232 | min-width: 160px; 233 | text-align: left; 234 | } 235 | 236 | .coin__symbol { 237 | display: block; 238 | margin-bottom: 10px; 239 | } 240 | 241 | .coin__logo { 242 | width: 50px; 243 | } 244 | 245 | .dark-mode { 246 | color: #fff; 247 | background-color: #313843; 248 | 249 | .navbar { 250 | background-color: #1F2022; 251 | border: none; 252 | } 253 | } 254 | --------------------------------------------------------------------------------