├── .env.example ├── .eslintrc.json ├── .gitignore ├── Capture.JPG ├── README.md ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico └── vercel.svg ├── src ├── components │ ├── Alert.tsx │ ├── Button.tsx │ ├── Form.tsx │ ├── Link.tsx │ ├── PageHeading.tsx │ ├── home-page │ │ ├── LogOfRecentTaskTimes.tsx │ │ ├── Taskbar.tsx │ │ └── Timer.tsx │ ├── layout │ │ ├── Footer.tsx │ │ ├── Layout.tsx │ │ └── Navbar.tsx │ ├── login-page │ │ └── LoginForm.tsx │ └── signup-page │ │ └── SignupForm.tsx ├── constants │ └── constants.ts ├── contexts │ ├── TasksContext.ts │ └── UserContext.ts ├── custom-hooks │ ├── useTasks.ts │ ├── useTimer.ts │ └── useUser.ts ├── middleware │ └── _defaultHandler.ts ├── pages │ ├── _app.tsx │ ├── api │ │ └── signup.ts │ ├── index.tsx │ ├── login.tsx │ ├── signup.tsx │ └── stats.tsx ├── styles │ └── globals.css ├── types │ ├── RecentTaskTime.ts │ └── Task.ts └── utils │ ├── formatTime.ts │ ├── harperdb │ ├── addNewTask.ts │ ├── createNewUser.ts │ ├── deleteTask.ts │ ├── fetchJWTTokens.ts │ ├── getTasks.ts │ ├── getUsername.ts │ ├── harperFetch.ts │ └── saveTaskTime.ts │ └── postFormData.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | HARPERDB_PW=Basic yourHarperDBPasswordHere -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | -------------------------------------------------------------------------------- /Capture.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DoableDanny/NextJS-HarperDB-Task-Timer/0b1eeed805984729f7e368f12da9549788bdd14f/Capture.JPG -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [Project link](https://next-js-harper-db-task-timer.vercel.app/). 2 | 3 | ![App home page](Capture.JPG) 4 | 5 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 6 | 7 | ## Getting Started 8 | 9 | First, run the development server: 10 | 11 | ```bash 12 | npm run dev 13 | # or 14 | yarn dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 20 | 21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 22 | 23 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "harperdb-nextjs-timer", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "next": "12.1.0", 13 | "next-connect": "^0.12.2", 14 | "react": "17.0.2", 15 | "react-dom": "17.0.2" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "17.0.21", 19 | "@types/react": "17.0.39", 20 | "autoprefixer": "^10.4.2", 21 | "eslint": "8.10.0", 22 | "eslint-config-next": "12.1.0", 23 | "postcss": "^8.4.7", 24 | "tailwindcss": "^3.0.23", 25 | "typescript": "4.6.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DoableDanny/NextJS-HarperDB-Task-Timer/0b1eeed805984729f7e368f12da9549788bdd14f/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/Alert.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | children: React.ReactNode; 3 | type: 'success' | 'warning' | 'danger'; 4 | key?: number; 5 | extraClasses?: string; 6 | } 7 | const Alert = ({ children, type, key, extraClasses }: Props) => { 8 | let color; 9 | switch (type) { 10 | case 'success': 11 | color = 'bg-blue-500'; 12 | break; 13 | case 'warning': 14 | color = 'bg-yellow-300 text-yellow-800'; 15 | break; 16 | default: 17 | color = 'bg-red-500'; 18 | } 19 | const classes = `text-white text-center p-2 rounded mt-4 ${color} ${extraClasses}`; 20 | 21 | return ( 22 |
23 | {children} 24 |
25 | ); 26 | }; 27 | 28 | export default Alert; 29 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | children: React.ReactNode; 3 | color: 'primary' | 'success' | 'secondary' | 'warning' | 'danger'; 4 | handleClick?: () => void; 5 | type?: 'button' | 'submit'; 6 | extraClasses?: string; 7 | } 8 | 9 | const Button: React.FC = ({ 10 | children, 11 | color, 12 | handleClick, 13 | type, 14 | extraClasses, 15 | }) => { 16 | let colors: string; 17 | switch (color) { 18 | case 'primary': 19 | colors = 'bg-blue-500 hover:bg-blue-600'; 20 | break; 21 | case 'success': 22 | colors = 'bg-green-500 hover:bg-green-600'; 23 | break; 24 | case 'warning': 25 | colors = 'bg-yellow-300 hover:bg-yellow-400 text-black'; 26 | break; 27 | case 'secondary': 28 | colors = 'bg-pink-500 hover:bg-pink-600'; 29 | break; 30 | default: 31 | colors = 'bg-red-500 hover:bg-red-600'; 32 | } 33 | const classes = `rounded text-white py-2 px-4 ${colors} ${extraClasses}`; 34 | 35 | return ( 36 | 39 | ); 40 | }; 41 | 42 | export default Button; 43 | -------------------------------------------------------------------------------- /src/components/Form.tsx: -------------------------------------------------------------------------------- 1 | interface InputProps { 2 | inputType: 'text' | 'email' | 'password'; 3 | inputName: string; 4 | handleChange: (e: React.ChangeEvent) => void; 5 | value: string; 6 | } 7 | 8 | interface LabelAndInputProps extends InputProps { 9 | label: string; 10 | } 11 | 12 | export const LabelAndInput: React.FC = ({ 13 | label, 14 | inputType, 15 | inputName, 16 | handleChange, 17 | value, 18 | }) => { 19 | return ( 20 |
21 | 22 | 28 |
29 | ); 30 | }; 31 | 32 | export const Input: React.FC = ({ 33 | inputType, 34 | inputName, 35 | handleChange, 36 | value, 37 | }) => { 38 | return ( 39 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import NextLink from 'next/link'; 2 | 3 | interface Props { 4 | href: string; 5 | children: React.ReactNode; 6 | } 7 | 8 | const Link = ({ href, children }: Props) => { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | }; 15 | 16 | export default Link; 17 | -------------------------------------------------------------------------------- /src/components/PageHeading.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | extraClasses?: string; 3 | } 4 | 5 | const PageHeading: React.FC = ({ children, extraClasses }) => { 6 | const classes = 'text-4xl text-green-900 font-semibold ' + extraClasses; 7 | 8 | return

{children}

; 9 | }; 10 | 11 | export default PageHeading; 12 | -------------------------------------------------------------------------------- /src/components/home-page/LogOfRecentTaskTimes.tsx: -------------------------------------------------------------------------------- 1 | import type { RecentTaskTime } from '../../types/RecentTaskTime'; 2 | 3 | interface Props { 4 | recentTaskTimes: RecentTaskTime[]; 5 | } 6 | 7 | const LogOfRecentTaskTimes = ({ recentTaskTimes }: Props) => { 8 | return ( 9 |
10 | {recentTaskTimes.map((t, i) => ( 11 |
12 |

13 | {t.seconds} seconds added to{' '} 14 | {t.name} 15 |

16 |
17 | ))} 18 |
19 | ); 20 | }; 21 | 22 | export default LogOfRecentTaskTimes; 23 | -------------------------------------------------------------------------------- /src/components/home-page/Taskbar.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import { harperAddNewTask } from '../../utils/harperdb/addNewTask'; 3 | import { UserContext } from '../../contexts/UserContext'; 4 | import { TasksContext } from '../../contexts/TasksContext'; 5 | import Button from '../Button'; 6 | 7 | interface Props { 8 | selectedTaskId: string; 9 | setSelectedTaskId: React.Dispatch>; 10 | setSelectedTaskName: React.Dispatch>; 11 | setErrorMessage: React.Dispatch>; 12 | setSeconds: React.Dispatch>; 13 | pauseTimer: () => void; 14 | } 15 | 16 | const TaskBar = ({ 17 | selectedTaskId, 18 | setSelectedTaskId, 19 | setSelectedTaskName, 20 | setErrorMessage, 21 | setSeconds, 22 | pauseTimer, 23 | }: Props) => { 24 | const { username } = useContext(UserContext); 25 | const { tasks, getAndSetTasks } = useContext(TasksContext); 26 | 27 | const [isUserAddingNewTask, setIsUserAddingNewTask] = useState(false); 28 | const [taskInputValue, setTaskInputValue] = useState(''); 29 | 30 | const handleChangeTaskInput = (e: { target: HTMLInputElement }) => { 31 | setTaskInputValue(e.target.value); 32 | }; 33 | 34 | const handleSelectTask = (e: { target: HTMLSelectElement }) => { 35 | const { options, selectedIndex, value } = e.target; 36 | const text = options[selectedIndex].text; 37 | 38 | setErrorMessage(''); 39 | setSelectedTaskId(value); 40 | setSelectedTaskName(text); 41 | setSeconds(0); 42 | pauseTimer(); 43 | }; 44 | 45 | const handleClickAddNewTask = () => { 46 | if (taskInputValue.trim() === '') { 47 | setErrorMessage('Type a task!'); 48 | return; 49 | } 50 | addNewTask(); 51 | resetAddingNewTask(); 52 | }; 53 | 54 | const addNewTask = async () => { 55 | try { 56 | const { response } = await harperAddNewTask(username, taskInputValue); 57 | if (response.status === 200) { 58 | // Task added to db successfully 59 | getAndSetTasks(username); 60 | } else setErrorMessage('Whoops, something went wrong'); 61 | } catch (err) { 62 | console.log(err); 63 | setErrorMessage('Whoops, something went wrong'); 64 | } 65 | }; 66 | 67 | const resetAddingNewTask = () => { 68 | setTaskInputValue(''); 69 | setIsUserAddingNewTask(false); 70 | }; 71 | 72 | return ( 73 |
74 | {isUserAddingNewTask ? ( 75 | <> 76 | 83 | 86 | 93 | 94 | ) : ( 95 | <> 96 | 117 | 123 | 124 | )} 125 |
126 | ); 127 | }; 128 | 129 | export default TaskBar; 130 | -------------------------------------------------------------------------------- /src/components/home-page/Timer.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { TasksContext } from '../../contexts/TasksContext'; 3 | import { UserContext } from '../../contexts/UserContext'; 4 | import { formatTime } from '../../utils/formatTime'; 5 | import { harperSaveTaskTime } from '../../utils/harperdb/saveTaskTime'; 6 | import Button from '../Button'; 7 | import type { RecentTaskTime } from '../../types/RecentTaskTime'; 8 | 9 | interface TimerProps { 10 | seconds: number; 11 | setSeconds: React.Dispatch>; 12 | isTimerOn: boolean; 13 | startTimer: () => void; 14 | pauseTimer: () => void; 15 | setErrorMessage: React.Dispatch>; 16 | selectedTaskId: string; 17 | selectedTaskName: string; 18 | setRecentTaskTimes: React.Dispatch>; 19 | } 20 | 21 | export const Timer: React.FC = ({ 22 | seconds, 23 | setSeconds, 24 | isTimerOn, 25 | startTimer, 26 | pauseTimer, 27 | setErrorMessage, 28 | selectedTaskId, 29 | selectedTaskName, 30 | setRecentTaskTimes, 31 | }) => { 32 | const { tasks, getAndSetTasks } = useContext(TasksContext); 33 | const { username } = useContext(UserContext); 34 | 35 | const { formattedHours, formattedMins, formattedSecs } = formatTime(seconds); 36 | 37 | const handleStartTimer = () => { 38 | setErrorMessage(''); 39 | if (selectedTaskId == '') { 40 | setErrorMessage('Please select a task'); 41 | } else { 42 | startTimer(); 43 | } 44 | }; 45 | 46 | const handleLogTime = async () => { 47 | pauseTimer(); 48 | const prevTaskSeconds = getTaskTimeFromId(selectedTaskId); 49 | const newTaskSeconds = prevTaskSeconds + seconds; 50 | const { response, result } = await harperSaveTaskTime( 51 | selectedTaskId, 52 | newTaskSeconds 53 | ); 54 | if (response.status === 200) { 55 | getAndSetTasks(username); 56 | setSeconds(0); 57 | setRecentTaskTimes((prev) => [ 58 | { name: selectedTaskName, seconds: seconds }, 59 | ...prev, 60 | ]); 61 | } else setErrorMessage('Whoops, something went wrong :('); 62 | console.log({ response, result }); 63 | }; 64 | 65 | const getTaskTimeFromId = (id: string) => { 66 | const task = tasks.find((task) => task.id === id); 67 | if (!task) return 0; 68 | return task.time_in_seconds; 69 | }; 70 | 71 | const handleResetTimer = () => { 72 | pauseTimer(); 73 | setSeconds(0); 74 | }; 75 | 76 | return ( 77 |
78 |
79 | {formattedHours} : {formattedMins} : {formattedSecs} 80 |
81 |
82 | {/* Pause and start the timer buttons */} 83 | {isTimerOn ? ( 84 | <> 85 | 88 | 89 | ) : ( 90 | 93 | )} 94 | 95 | {/* Button to update the time in the db for the chosen task */} 96 | {(seconds > 0 || isTimerOn) && ( 97 | 104 | )} 105 |
106 | 107 | {/* Stop timer and reset to 0 secs */} 108 | {(seconds > 0 || isTimerOn) && ( 109 | 115 | )} 116 |
117 | ); 118 | }; 119 | 120 | interface TimerBtnProps { 121 | handleClick: () => void; 122 | text: string; 123 | extraClasses?: string; 124 | } 125 | 126 | export const TimerBtn: React.FC = ({ 127 | handleClick, 128 | text, 129 | extraClasses, 130 | }) => { 131 | return ( 132 | 140 | ); 141 | }; 142 | -------------------------------------------------------------------------------- /src/components/layout/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { SITE_TITLE } from '../../constants/constants'; 2 | 3 | const Footer = () => { 4 | return ( 5 |
6 |

{SITE_TITLE} ©

7 |

Designed & developed by Danny Adams

8 |
9 | ); 10 | }; 11 | 12 | export default Footer; 13 | -------------------------------------------------------------------------------- /src/components/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from './Navbar'; 2 | import Footer from './Footer'; 3 | 4 | const Layout: React.FC = ({ children }) => { 5 | return ( 6 |
7 | 8 |
{children}
9 |
10 |
11 |
12 |
13 | ); 14 | }; 15 | 16 | export default Layout; 17 | -------------------------------------------------------------------------------- /src/components/layout/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { useContext } from 'react'; 3 | import { SITE_TITLE } from '../../constants/constants'; 4 | import { UserContext } from '../../contexts/UserContext'; 5 | 6 | const Navbar = () => { 7 | const { username, setUsername } = useContext(UserContext); 8 | 9 | const handleLogout = () => { 10 | localStorage.removeItem('access_token'); 11 | setUsername(''); 12 | }; 13 | 14 | return ( 15 |
16 |

17 | 18 | {SITE_TITLE} 19 | 20 |

21 | 43 |
44 | ); 45 | }; 46 | 47 | interface NavLinkProps { 48 | href: string; 49 | children: string; 50 | } 51 | 52 | const NavLink: React.FC = ({ href, children }) => { 53 | return ( 54 |
  • 55 | 56 | {children} 57 | 58 |
  • 59 | ); 60 | }; 61 | 62 | export default Navbar; 63 | -------------------------------------------------------------------------------- /src/components/login-page/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import { LabelAndInput } from '../Form'; 3 | import Button from '../Button'; 4 | import Alert from '../Alert'; 5 | 6 | import { UserContext } from '../../contexts/UserContext'; 7 | import { harperFetchJWTTokens } from '../../utils/harperdb/fetchJWTTokens'; 8 | 9 | const LoginForm = () => { 10 | const [username, setUsername] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | 13 | const [error, setError] = useState(''); 14 | const user = useContext(UserContext); 15 | 16 | const handleSubmit = async (e: React.FormEvent) => { 17 | e.preventDefault(); 18 | setError(''); 19 | if (!username || !password) { 20 | setError('Username and password required'); 21 | return; 22 | } 23 | 24 | try { 25 | const { response, result } = await harperFetchJWTTokens( 26 | username, 27 | password 28 | ); 29 | const { status } = response; 30 | const accessToken = result.operation_token; 31 | if (status === 200 && accessToken) { 32 | authenticateUser(username, accessToken); 33 | } else if (status === 401) { 34 | setError('Check your username and password are correct'); 35 | } else { 36 | setError('Whoops, something went wrong :('); 37 | } 38 | } catch (err) { 39 | console.log(err); 40 | setError('Whoops, something went wrong :('); 41 | } 42 | }; 43 | 44 | const authenticateUser = (username: string, accessToken: string) => { 45 | user.setUsername(username); 46 | localStorage.setItem('access_token', accessToken); 47 | }; 48 | 49 | return ( 50 |
    51 | setUsername(e.target.value)} 56 | value={username} 57 | /> 58 | setPassword(e.target.value)} 63 | value={password} 64 | /> 65 | 68 | 69 | {error && {error}} 70 | 71 | ); 72 | }; 73 | 74 | export default LoginForm; 75 | -------------------------------------------------------------------------------- /src/components/signup-page/SignupForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import { UserContext } from '../../contexts/UserContext'; 3 | import { useRouter } from 'next/router'; 4 | 5 | import { LabelAndInput } from '../Form'; 6 | import Button from '../Button'; 7 | import Alert from '../Alert'; 8 | import { postFormData } from '../../utils/postFormData'; 9 | 10 | import { harperFetchJWTTokens } from '../../utils/harperdb/fetchJWTTokens'; 11 | 12 | const SignupForm = () => { 13 | const [username, setUsername] = useState(''); 14 | const [password1, setPassword1] = useState(''); 15 | const [password2, setPassword2] = useState(''); 16 | const [errors, setErrors] = useState(''); 17 | 18 | const user = useContext(UserContext); 19 | const router = useRouter(); 20 | 21 | const handleSubmit = async (e: React.FormEvent) => { 22 | e.preventDefault(); 23 | setErrors(''); 24 | 25 | const formData = { username, password1, password2 }; 26 | const { response, result } = await postFormData(formData, '/api/signup'); 27 | 28 | // Account not created successfully 29 | if (response.status !== 200) { 30 | setErrors(result.error); 31 | return; 32 | } 33 | 34 | // Account created successfully; get JWTs 35 | try { 36 | const { response, result } = await harperFetchJWTTokens( 37 | username, 38 | password1 39 | ); 40 | const accessToken = result.operation_token; 41 | if (response.status === 200 && accessToken) { 42 | authenticateUser(username, accessToken); 43 | } else { 44 | // Account created, but failed to get JWTs 45 | // Redirect to login page 46 | router.push('/login'); 47 | } 48 | } catch (err) { 49 | console.log(err); 50 | setErrors('Whoops, something went wrong :('); 51 | } 52 | 53 | console.log({ response, result }); 54 | }; 55 | 56 | const authenticateUser = (username: string, accessToken: string) => { 57 | user.setUsername(username); 58 | localStorage.setItem('access_token', accessToken); 59 | }; 60 | 61 | const displayErrors = () => { 62 | if (errors.length === 0) return; 63 | 64 | return typeof errors === 'string' ? ( 65 | {errors} 66 | ) : ( 67 | errors.map((err, i) => ( 68 | 69 | {err} 70 | 71 | )) 72 | ); 73 | }; 74 | 75 | return ( 76 |
    77 | setUsername(e.target.value)} 82 | value={username} 83 | /> 84 | setPassword1(e.target.value)} 89 | value={password1} 90 | /> 91 | setPassword2(e.target.value)} 96 | value={password2} 97 | /> 98 | 105 | 106 | {displayErrors()} 107 | 108 | ); 109 | }; 110 | 111 | export default SignupForm; 112 | -------------------------------------------------------------------------------- /src/constants/constants.ts: -------------------------------------------------------------------------------- 1 | export const SITE_TITLE = 'Super Simple Task Timer'; 2 | export const DB_URL = 'https://cloud-1-doabledanny.harperdbcloud.com'; 3 | -------------------------------------------------------------------------------- /src/contexts/TasksContext.ts: -------------------------------------------------------------------------------- 1 | import React, { createContext } from 'react'; 2 | import type { Task } from '../types/Task'; 3 | 4 | interface TasksContext { 5 | tasks: Task[]; 6 | setTasks: React.Dispatch>; 7 | getAndSetTasks: (username: string) => Promise; 8 | } 9 | 10 | export const TasksContext = createContext({} as TasksContext); 11 | -------------------------------------------------------------------------------- /src/contexts/UserContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export const UserContext = createContext({ 4 | username: '', 5 | setUsername: (username: string) => {}, 6 | }); 7 | -------------------------------------------------------------------------------- /src/custom-hooks/useTasks.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react'; 2 | import type { Task } from '../types/Task'; 3 | import { harperGetTasks } from '../utils/harperdb/getTasks'; 4 | 5 | export const useTasks = (username: string) => { 6 | const [tasks, setTasks] = useState([]); 7 | 8 | // Get tasks from db then set task state 9 | const getAndSetTasks = useCallback( 10 | async (username: string) => { 11 | try { 12 | const tasks: Task[] = await harperGetTasks(username); 13 | setTasks(tasks); 14 | } catch (err) { 15 | console.log(err); 16 | } 17 | }, 18 | [setTasks] 19 | ); 20 | 21 | useEffect(() => { 22 | if (!username || tasks.length > 0) return; 23 | getAndSetTasks(username); 24 | }, [username, tasks.length, getAndSetTasks]); 25 | 26 | return { tasks, setTasks, getAndSetTasks }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/custom-hooks/useTimer.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react'; 2 | 3 | const useTimer = () => { 4 | const [isTimerOn, setIsTimerOn] = useState(false); 5 | const [seconds, setSeconds] = useState(0); 6 | 7 | const intervalRef = useRef(null); 8 | 9 | const startTimer = () => { 10 | setIsTimerOn(true); 11 | 12 | const intervalId = setInterval(() => { 13 | setSeconds((prev) => prev + 1); 14 | }, 1000); 15 | 16 | intervalRef.current = intervalId; 17 | }; 18 | 19 | const pauseTimer = () => { 20 | setIsTimerOn(false); 21 | clearInterval(intervalRef.current as NodeJS.Timeout); 22 | }; 23 | 24 | return { 25 | isTimerOn, 26 | seconds, 27 | setSeconds, 28 | startTimer, 29 | pauseTimer, 30 | }; 31 | }; 32 | 33 | export default useTimer; 34 | -------------------------------------------------------------------------------- /src/custom-hooks/useUser.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { harperGetUsername } from '../utils/harperdb/getUsername'; 3 | 4 | export const useUser = () => { 5 | const [username, setUsername] = useState(''); 6 | 7 | useEffect(() => { 8 | // User is logged in 9 | if (username) return; 10 | 11 | // Check for access token and try to log user in 12 | const accessToken = localStorage.getItem('access_token'); 13 | if (accessToken) { 14 | tryLogUserIn(accessToken); 15 | } 16 | 17 | async function tryLogUserIn(accessToken: string) { 18 | const username = await harperGetUsername(accessToken); 19 | if (username) { 20 | setUsername(username); 21 | } 22 | } 23 | }); 24 | 25 | return { username, setUsername }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/middleware/_defaultHandler.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import nextConnect from 'next-connect'; 3 | 4 | // This middleware function will run between every request and api handler 5 | const handler = nextConnect({ 6 | onError: (err, req, res) => { 7 | res.status(501).json({ error: `Something went wrong! ${err.message}` }); 8 | }, 9 | onNoMatch: (req, res) => { 10 | res.status(405).json({ error: `Method ${req.method} Not Allowed` }); 11 | }, 12 | }); 13 | 14 | export default handler; 15 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | import Layout from '../components/layout/Layout'; 4 | import { UserContext } from '../contexts/UserContext'; 5 | import { useUser } from '../custom-hooks/useUser'; 6 | 7 | import { TasksContext } from '../contexts/TasksContext'; 8 | import { useTasks } from '../custom-hooks/useTasks'; 9 | 10 | function MyApp({ Component, pageProps }: AppProps) { 11 | const { username, setUsername } = useUser(); 12 | 13 | const { tasks, setTasks, getAndSetTasks } = useTasks(username); 14 | 15 | console.log(tasks); 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export default MyApp; 29 | -------------------------------------------------------------------------------- /src/pages/api/signup.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next'; 2 | import handler from '../../middleware/_defaultHandler'; 3 | import { harperCreateNewUser } from '../../utils/harperdb/createNewUser'; 4 | 5 | export default handler.post( 6 | async (req: NextApiRequest, res: NextApiResponse) => { 7 | const { username, password1, password2 } = req.body; 8 | 9 | const errors: string[] = getFormErrors(username, password1, password2); 10 | if (errors.length > 0) { 11 | return res.status(400).json({ error: errors }); 12 | } 13 | 14 | // Create new user with HarperDB, and send back result 15 | try { 16 | const { response, result } = await harperCreateNewUser( 17 | username, 18 | password1 19 | ); 20 | return res.status(response.status).json(result); 21 | } catch (err) { 22 | return res.status(500).json({ error: err }); 23 | } 24 | } 25 | ); 26 | 27 | const getFormErrors = ( 28 | username: string, 29 | password1: string, 30 | password2: string 31 | ) => { 32 | const errors: string[] = []; 33 | if (!username || !password1 || !password2) { 34 | errors.push('All fields are required'); 35 | } 36 | if (password1.length < 6) { 37 | errors.push('Password must be at least 6 characters'); 38 | } 39 | if (password1 !== password2) { 40 | errors.push('Passwords do not match'); 41 | } 42 | return errors; 43 | }; 44 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import type { NextPage } from 'next'; 3 | import type { RecentTaskTime } from '../types/RecentTaskTime'; 4 | import { UserContext } from '../contexts/UserContext'; 5 | import useTimer from '../custom-hooks/useTimer'; 6 | import Taskbar from '../components/home-page/Taskbar'; 7 | import { Timer } from '../components/home-page/Timer'; 8 | import Alert from '../components/Alert'; 9 | import Link from '../components/Link'; 10 | import LogOfRecentTaskTimes from '../components/home-page/LogOfRecentTaskTimes'; 11 | 12 | const Home: NextPage = () => { 13 | const [selectedTaskId, setSelectedTaskId] = useState(''); 14 | const [selectedTaskName, setSelectedTaskName] = useState(''); 15 | const [errorMessage, setErrorMessage] = useState(''); 16 | const [recentTaskTimes, setRecentTaskTimes] = useState([]); 17 | 18 | const { isTimerOn, seconds, setSeconds, startTimer, pauseTimer } = useTimer(); 19 | 20 | const { username } = useContext(UserContext); 21 | 22 | return ( 23 |
    24 | {!username && ( 25 | 26 | Please log in or{' '} 27 | create an account to use Super 28 | Productivity Timer 29 | 30 | )} 31 | 32 | 40 | 51 | 52 | {errorMessage &&
    {errorMessage}
    } 53 | 54 | {recentTaskTimes.length > 0 && ( 55 | 56 | )} 57 |
    58 | ); 59 | }; 60 | 61 | export default Home; 62 | -------------------------------------------------------------------------------- /src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import type { NextPage } from 'next'; 3 | import { UserContext } from '../contexts/UserContext'; 4 | import PageHeading from '../components/PageHeading'; 5 | import LoginForm from '../components/login-page/LoginForm'; 6 | 7 | const Login: NextPage = () => { 8 | const { username } = useContext(UserContext); 9 | 10 | return ( 11 |
    12 | {username ? ( 13 |

    14 | You are logged in as{' '} 15 | {username} 👋 16 |

    17 | ) : ( 18 | <> 19 | Log in 20 | 21 | 22 | )} 23 |
    24 | ); 25 | }; 26 | 27 | export default Login; 28 | -------------------------------------------------------------------------------- /src/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { UserContext } from '../contexts/UserContext'; 3 | import Alert from '../components/Alert'; 4 | 5 | import type { NextPage } from 'next'; 6 | import SignupForm from '../components/signup-page/SignupForm'; 7 | import PageHeading from '../components/PageHeading'; 8 | 9 | const Signup: NextPage = () => { 10 | const { username } = useContext(UserContext); 11 | 12 | return ( 13 |
    14 | {username ? ( 15 | You are logged in as {username} 16 | ) : ( 17 | <> 18 | 19 | Create an account 20 | 21 | 22 | 23 | )} 24 |
    25 | ); 26 | }; 27 | 28 | export default Signup; 29 | -------------------------------------------------------------------------------- /src/pages/stats.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from 'react'; 2 | import type { NextPage } from 'next'; 3 | import { UserContext } from '../contexts/UserContext'; 4 | import { TasksContext } from '../contexts/TasksContext'; 5 | import Header from '../components/PageHeading'; 6 | import Link from '../components/Link'; 7 | import Alert from '../components/Alert'; 8 | import { 9 | displayTimeString, 10 | timestampToDayMonthYear, 11 | } from '../utils/formatTime'; 12 | import { harperDeleteTask } from '../utils/harperdb/deleteTask'; 13 | 14 | const Stats: NextPage = () => { 15 | const [errorMessage, setErrorMessage] = useState(''); 16 | 17 | const { username } = useContext(UserContext); 18 | const { tasks, getAndSetTasks } = useContext(TasksContext); 19 | 20 | const handleDeleteRow = async (taskId: string) => { 21 | setErrorMessage(''); 22 | const areYouSure = confirm('Are you sure you want to delete this row?'); 23 | if (!areYouSure) return; 24 | 25 | try { 26 | // Delete task from db 27 | const { response } = await harperDeleteTask(taskId); 28 | if (response.status === 200) { 29 | // Get tasks from db and setTasks 30 | getAndSetTasks(username); 31 | return; 32 | } 33 | } catch (err) { 34 | console.log(err); 35 | } 36 | setErrorMessage('Whoops, something went wrong :('); 37 | }; 38 | 39 | return ( 40 |
    41 | {!username && ( 42 | 43 | Please log in or{' '} 44 | create an account to use Super 45 | Productivity Timer 46 | 47 | )} 48 | 49 |
    Stats
    50 | 51 | {errorMessage && ( 52 |

    {errorMessage}

    53 | )} 54 | 55 |
    56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | {tasks.length > 0 && 68 | tasks.map((task) => ( 69 | 70 | 71 | 72 | 73 | 74 | 82 | 83 | ))} 84 | 85 |
    TaskTotal TimeLast UpdatedStart DateDelete
    {task.task_name}{displayTimeString(task.time_in_seconds)}{timestampToDayMonthYear(task.__updatedtime__)}{timestampToDayMonthYear(task.__createdtime__)} 75 | 81 |
    86 |
    87 |
    88 | ); 89 | }; 90 | 91 | const TH: React.FC<{ children: string }> = ({ children }) => { 92 | const classes = 'border border-slate-300 rounded-top p-4'; 93 | return {children}; 94 | }; 95 | 96 | interface TDProps { 97 | children: React.ReactNode; 98 | } 99 | const TD = ({ children }: TDProps) => { 100 | const classes = 'border border-slate-300 p-4'; 101 | return {children}; 102 | }; 103 | 104 | export default Stats; 105 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/types/RecentTaskTime.ts: -------------------------------------------------------------------------------- 1 | export interface RecentTaskTime { 2 | name: string; 3 | seconds: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/Task.ts: -------------------------------------------------------------------------------- 1 | export interface Task { 2 | __createdtime__: number; 3 | __updatedtime__: number; 4 | username: string; 5 | time_in_seconds: number; 6 | id: string; 7 | task_name: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/formatTime.ts: -------------------------------------------------------------------------------- 1 | const SECONDS_PER_HOUR = 3600; 2 | const SECONDS_PER_MINUTE = 60; 3 | 4 | export const displayTimeString = (seconds: number) => { 5 | const { formattedHours, formattedMins, formattedSecs } = formatTime(seconds); 6 | return `${formattedHours}h ${formattedMins}m ${formattedSecs}s`; 7 | }; 8 | 9 | // timestamp => dd/mm/yyyy 10 | export const timestampToDayMonthYear = (timestamp: number) => { 11 | const date = new Date(timestamp); 12 | const formattedDate = date.toLocaleDateString(); 13 | return formattedDate; 14 | }; 15 | 16 | // HH:MM:SS 17 | export const formatTime = (seconds: number) => { 18 | const { hours, mins, secs } = calculateHoursMinsAndSecs(seconds); 19 | 20 | const formattedHours = prependZeroIfLessThanTen(hours); 21 | const formattedMins = prependZeroIfLessThanTen(mins); 22 | const formattedSecs = prependZeroIfLessThanTen(secs); 23 | 24 | return { 25 | formattedHours, 26 | formattedMins, 27 | formattedSecs, 28 | }; 29 | }; 30 | 31 | // Prefix time with 0 if less than 10. E.g. '1' => '01'. 32 | const prependZeroIfLessThanTen = (time: number) => { 33 | const formattedTime: string = time < 10 ? `0${time}` : `${time}`; 34 | return formattedTime; 35 | }; 36 | 37 | // Convert seconds into hours, mins, and secs 38 | const calculateHoursMinsAndSecs = (seconds: number) => { 39 | const hours = calculateHours(seconds); 40 | const mins = calculateMins(seconds); 41 | const secs = calculateSecs(seconds); 42 | 43 | return { 44 | hours, 45 | mins, 46 | secs, 47 | }; 48 | }; 49 | 50 | const calculateHours = (seconds: number) => { 51 | const hours = Math.floor(seconds / SECONDS_PER_HOUR); 52 | return hours; 53 | }; 54 | 55 | const calculateMins = (seconds: number) => { 56 | const mins = Math.floor((seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); 57 | return mins; 58 | }; 59 | 60 | const calculateSecs = (seconds: number) => { 61 | const secs = Math.floor((seconds % SECONDS_PER_HOUR) % SECONDS_PER_MINUTE); 62 | return secs; 63 | }; 64 | -------------------------------------------------------------------------------- /src/utils/harperdb/addNewTask.ts: -------------------------------------------------------------------------------- 1 | import { harperFetch } from './harperFetch'; 2 | 3 | export const harperAddNewTask = async (username: string, taskName: string) => { 4 | const data = { 5 | operation: 'insert', 6 | schema: 'productivity_timer', 7 | table: 'tasks', 8 | records: [ 9 | { 10 | username: username, 11 | task_name: taskName, 12 | time_in_seconds: 0, 13 | }, 14 | ], 15 | }; 16 | 17 | const responseAndResult = await harperFetch(data); 18 | return responseAndResult; 19 | }; 20 | -------------------------------------------------------------------------------- /src/utils/harperdb/createNewUser.ts: -------------------------------------------------------------------------------- 1 | import { DB_URL } from '../../constants/constants'; 2 | 3 | // This function can only be ran on the backend as it requires a "super_user" password 4 | export const harperCreateNewUser = async ( 5 | username: string, 6 | password: string 7 | ) => { 8 | const DB_PW = process.env.HARPERDB_PW; 9 | if (!DB_URL || !DB_PW) { 10 | console.log('Error: .env variables are undefined'); 11 | throw 'Internal server error'; 12 | } 13 | const myHeaders = new Headers(); 14 | myHeaders.append('Content-Type', 'application/json'); 15 | myHeaders.append('Authorization', DB_PW); 16 | const raw = JSON.stringify({ 17 | operation: 'add_user', 18 | role: 'standard_user', 19 | username: username.toLowerCase(), 20 | password: password, 21 | active: true, 22 | }); 23 | const requestOptions: RequestInit = { 24 | method: 'POST', 25 | headers: myHeaders, 26 | body: raw, 27 | redirect: 'follow', 28 | }; 29 | 30 | const response = await fetch(DB_URL, requestOptions); 31 | const result = await response.json(); 32 | return { response, result }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/utils/harperdb/deleteTask.ts: -------------------------------------------------------------------------------- 1 | import { harperFetch } from './harperFetch'; 2 | 3 | export const harperDeleteTask = async (taskId: string) => { 4 | const data = { 5 | operation: 'delete', 6 | schema: 'productivity_timer', 7 | table: 'tasks', 8 | hash_values: [taskId], 9 | }; 10 | 11 | const responseAndResult = await harperFetch(data); 12 | return responseAndResult; 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/harperdb/fetchJWTTokens.ts: -------------------------------------------------------------------------------- 1 | import { DB_URL } from '../../constants/constants'; 2 | 3 | export const harperFetchJWTTokens = async ( 4 | username: string, 5 | password: string 6 | ) => { 7 | if (!DB_URL) { 8 | console.log('Error: DB_URL undefined'); 9 | throw 'Internal server error'; 10 | } 11 | 12 | const myHeaders = new Headers(); 13 | myHeaders.append('Content-Type', 'application/json'); 14 | 15 | const raw = JSON.stringify({ 16 | operation: 'create_authentication_tokens', 17 | username: username, 18 | password: password, 19 | }); 20 | 21 | const requestOptions: RequestInit = { 22 | method: 'POST', 23 | headers: myHeaders, 24 | body: raw, 25 | redirect: 'follow', 26 | }; 27 | 28 | const response = await fetch(DB_URL, requestOptions); 29 | const result = await response.json(); 30 | return { response, result }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/utils/harperdb/getTasks.ts: -------------------------------------------------------------------------------- 1 | import { harperFetch } from './harperFetch'; 2 | 3 | export const harperGetTasks = async (username: string) => { 4 | const data = { 5 | operation: 'sql', 6 | sql: `SELECT * FROM productivity_timer.tasks WHERE username = '${username}' ORDER BY __updatedtime__ DESC`, 7 | }; 8 | 9 | const { result } = await harperFetch(data); 10 | return result; 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/harperdb/getUsername.ts: -------------------------------------------------------------------------------- 1 | import { DB_URL } from '../../constants/constants'; 2 | 3 | export const harperGetUsername = async (accessToken: string) => { 4 | const myHeaders = new Headers(); 5 | myHeaders.append('Content-Type', 'application/json'); 6 | myHeaders.append('Authorization', 'Bearer ' + accessToken); 7 | 8 | const raw = JSON.stringify({ 9 | operation: 'user_info', 10 | }); 11 | 12 | const requestOptions: RequestInit = { 13 | method: 'POST', 14 | headers: myHeaders, 15 | body: raw, 16 | redirect: 'follow', 17 | }; 18 | 19 | try { 20 | const response = await fetch(DB_URL, requestOptions); 21 | const result = await response.json(); 22 | if (response.status === 200) { 23 | return result.username; 24 | } 25 | } catch (err) { 26 | console.log(err); 27 | } 28 | return null; 29 | }; 30 | -------------------------------------------------------------------------------- /src/utils/harperdb/harperFetch.ts: -------------------------------------------------------------------------------- 1 | import { DB_URL } from '../../constants/constants'; 2 | 3 | export const harperFetch = async (data: { [key: string]: any }) => { 4 | const accessToken = localStorage.getItem('access_token'); 5 | if (!accessToken) throw { error: 'You need to log in' }; 6 | 7 | const myHeaders = new Headers(); 8 | myHeaders.append('Content-Type', 'application/json'); 9 | myHeaders.append('Authorization', 'Bearer ' + accessToken); 10 | 11 | const raw = JSON.stringify(data); 12 | 13 | const requestOptions: RequestInit = { 14 | method: 'POST', 15 | headers: myHeaders, 16 | body: raw, 17 | redirect: 'follow', 18 | }; 19 | 20 | const response = await fetch(DB_URL, requestOptions); 21 | const result = await response.json(); 22 | return { response, result }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils/harperdb/saveTaskTime.ts: -------------------------------------------------------------------------------- 1 | import { harperFetch } from './harperFetch'; 2 | 3 | export const harperSaveTaskTime = async ( 4 | taskId: string, 5 | newSeconds: number 6 | ) => { 7 | const data = { 8 | operation: 'sql', 9 | sql: `UPDATE productivity_timer.tasks SET time_in_seconds = '${newSeconds}' WHERE id = '${taskId}'`, 10 | }; 11 | 12 | const responseAndResult = await harperFetch(data); 13 | return responseAndResult; 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/postFormData.ts: -------------------------------------------------------------------------------- 1 | export const postFormData = async (data: { [k: string]: any }, url: string) => { 2 | const requestOptions: RequestInit = { 3 | method: 'POST', 4 | headers: { 5 | 'Content-Type': 'application/json', 6 | }, 7 | body: JSON.stringify(data), 8 | }; 9 | const response = await fetch(url, requestOptions); 10 | const result = await response.json(); 11 | return { response, result }; 12 | }; 13 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./src/pages/**/*.{js,ts,jsx,tsx}", 4 | "./src/components/**/*.{js,ts,jsx,tsx}", 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [], 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------