├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── nginx.default.conf ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.test.tsx ├── App.tsx ├── MicroblogApiClient.tsx ├── Schemas.tsx ├── components │ ├── Body.tsx │ ├── FlashMessage.tsx │ ├── Header.tsx │ ├── InputField.tsx │ ├── More.tsx │ ├── Post.test.tsx │ ├── Post.tsx │ ├── Posts.tsx │ ├── PrivateRoute.tsx │ ├── PublicRoute.tsx │ ├── Sidebar.tsx │ ├── TimeAgo.tsx │ └── Write.tsx ├── contexts │ ├── ApiProvider.tsx │ ├── FlashProvider.test.tsx │ ├── FlashProvider.tsx │ ├── UserProvider.test.tsx │ └── UserProvider.tsx ├── index.css ├── index.tsx ├── pages │ ├── ChangePasswordPage.tsx │ ├── EditUserPage.tsx │ ├── ExplorePage.tsx │ ├── FeedPage.tsx │ ├── LoginPage.tsx │ ├── RegistrationPage.tsx │ ├── ResetPage.tsx │ ├── ResetRequestPage.tsx │ └── UserPage.tsx ├── react-app-env.d.ts ├── reportWebVitals.ts └── setupTests.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Node.js stuff: 69 | node_modules 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # database 135 | *.sqlite 136 | 137 | # PyCharm 138 | .idea 139 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | COPY build/ /usr/share/nginx/html/ 3 | COPY nginx.default.conf /etc/nginx/conf.d/default.conf 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React-Microblog-TS 2 | ================== 3 | 4 | This is a version of the microblogging application featured in my 5 | [React Mega-Tutorial](https://blog.miguelgrinberg.com/post/introducing-the-react-mega-tutorial) 6 | book, but written in TypeScript instead of plain JavaScript. 7 | 8 | After you have learned with my book, you can compare the 9 | [original code](https://github.com/miguelgrinberg/react-microblog) against 10 | this repository to learn what are the changes you have to adopt if you want to 11 | write your application in TypeScript with full typing checks. 12 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | frontend: 4 | build: . 5 | image: react-microblog 6 | ports: 7 | - "8080:80" 8 | restart: always 9 | api: 10 | build: ../microblog-api 11 | image: microblog-api 12 | volumes: 13 | - type: volume 14 | source: data 15 | target: /data 16 | env_file: .env.api 17 | environment: 18 | DATABASE_URL: sqlite:////data/db.sqlite 19 | restart: always 20 | volumes: 21 | data: 22 | -------------------------------------------------------------------------------- /nginx.default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | root /usr/share/nginx/html; 6 | index index.html; 7 | 8 | location / { 9 | try_files $uri $uri/ /index.html; 10 | add_header Cache-Control "no-cache"; 11 | } 12 | 13 | location /static { 14 | expires 1y; 15 | add_header Cache-Control "public"; 16 | } 17 | 18 | location /api { 19 | proxy_pass http://api:5000; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-microblog-ts", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.36", 11 | "@types/react": "^18.2.14", 12 | "@types/react-dom": "^18.2.6", 13 | "bootstrap": "^5.3.0", 14 | "react": "^18.2.0", 15 | "react-bootstrap": "^2.8.0", 16 | "react-dom": "^18.2.0", 17 | "react-router-dom": "^6.14.0", 18 | "react-scripts": "5.0.1", 19 | "serve": "^14.2.0", 20 | "typescript": "^4.9.5", 21 | "web-vitals": "^2.1.4" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "deploy": "npm run build && docker-compose build && docker-compose up -d", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/react-microblog-ts/44777920d971836ed5c391e37ddb9f80079eb83d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Microblog 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/react-microblog-ts/44777920d971836ed5c391e37ddb9f80079eb83d/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/miguelgrinberg/react-microblog-ts/44777920d971836ed5c391e37ddb9f80079eb83d/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Microblog", 3 | "name": "React Microblog TypeScript", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders brand element', () => { 6 | render(); 7 | const element = screen.getByText(/Microblog/); 8 | 9 | expect(element).toBeInTheDocument(); 10 | expect(element).toHaveClass('navbar-brand'); 11 | }); 12 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Container from 'react-bootstrap/Container'; 2 | import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; 3 | import FlashProvider from './contexts/FlashProvider'; 4 | import ApiProvider from './contexts/ApiProvider'; 5 | import UserProvider from './contexts/UserProvider'; 6 | import Header from './components/Header'; 7 | import PublicRoute from './components/PublicRoute'; 8 | import PrivateRoute from './components/PrivateRoute'; 9 | import FeedPage from './pages/FeedPage'; 10 | import ExplorePage from './pages/ExplorePage'; 11 | import UserPage from './pages/UserPage'; 12 | import EditUserPage from './pages/EditUserPage'; 13 | import ChangePasswordPage from './pages/ChangePasswordPage'; 14 | import LoginPage from './pages/LoginPage'; 15 | import RegistrationPage from './pages/RegistrationPage'; 16 | import ResetRequestPage from './pages/ResetRequestPage'; 17 | import ResetPage from './pages/ResetPage'; 18 | 19 | export default function App() { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 30 | } /> 31 | 33 | } /> 34 | 36 | } /> 37 | 39 | } /> 40 | 42 | 43 | } /> 44 | } /> 45 | } /> 46 | } /> 47 | } /> 48 | } /> 49 | 50 | 51 | } /> 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/MicroblogApiClient.tsx: -------------------------------------------------------------------------------- 1 | const BASE_API_URL = process.env.REACT_APP_BASE_API_URL; 2 | 3 | type Query = Record; 4 | type Headers = Record; 5 | 6 | type Options = { 7 | method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; 8 | url?: string; 9 | query?: Query; 10 | headers?: Headers; 11 | body?: TREQ; 12 | } 13 | 14 | type Response = { 15 | ok: boolean; 16 | status: number; 17 | body: TRES | null; 18 | errors: {json: any} | null; 19 | } 20 | 21 | type Token = { 22 | access_token: string | null; 23 | } 24 | 25 | export default class MicroblogApiClient { 26 | base_url = BASE_API_URL + '/api'; 27 | onError: (error: any) => void; 28 | 29 | constructor(onError: (error: any) => void) { 30 | this.onError = onError; 31 | } 32 | 33 | async request(options: Options): Promise> { 34 | let response = await this.requestInternal(options); 35 | if (response.status === 401 && options.url !== '/tokens') { 36 | const refreshResponse = await this.put('/tokens', { 37 | access_token: localStorage.getItem('accessToken'), 38 | }); 39 | if (refreshResponse.ok && refreshResponse.body) { 40 | localStorage.setItem('accessToken', refreshResponse.body.access_token || ''); 41 | response = await this.requestInternal(options); 42 | } 43 | } 44 | if (response.status >= 500 && this.onError) { 45 | this.onError(response); 46 | } 47 | return response; 48 | } 49 | 50 | async requestInternal(options: Options): Promise> { 51 | let query = new URLSearchParams(options.query || {}).toString(); 52 | if (query !== '') { 53 | query = '?' + query; 54 | } 55 | 56 | let response; 57 | try { 58 | response = await fetch(this.base_url + options.url + query, { 59 | method: options.method, 60 | headers: { 61 | 'Content-Type': 'application/json', 62 | 'Authorization': 'Bearer ' + localStorage.getItem('accessToken'), 63 | ...options.headers, 64 | }, 65 | credentials: options.url === '/tokens' ? 'include' : 'omit', 66 | body: options.body ? JSON.stringify(options.body) : null, 67 | }); 68 | } 69 | catch (error: any) { 70 | response = { 71 | ok: false, 72 | status: 500, 73 | json: async () => { return { 74 | code: 500, 75 | message: 'The server is unresponsive', 76 | description: error.toString(), 77 | }; } 78 | }; 79 | } 80 | 81 | const payload = response.status !== 204 ? await response.json() : null; 82 | return { 83 | ok: response.ok, 84 | status: response.status, 85 | body: response.status < 400 ? payload : null, 86 | errors: response.status >= 400 ? payload.errors : null, 87 | }; 88 | } 89 | 90 | async get(url: string, query?: Query, options?: Options): Promise> { 91 | return this.request({method: 'GET', url, query, ...options}); 92 | } 93 | 94 | async post(url: string, body?: TREQ, options?: Options): Promise> { 95 | return this.request({method: 'POST', url, body, ...options}); 96 | } 97 | 98 | async put(url: string, body?: TREQ, options?: Options): Promise> { 99 | return this.request({method: 'PUT', url, body, ...options}); 100 | } 101 | 102 | async delete(url: string, options?: Options) { 103 | return this.request({method: 'DELETE', url, ...options}); 104 | } 105 | 106 | async login(username: string, password: string): Promise<'ok' | 'fail' | 'error'> { 107 | const response = await this.post('/tokens', null, { 108 | headers: { 109 | Authorization: 'Basic ' + btoa(username + ":" + password) 110 | } 111 | }); 112 | if (!response.ok || !response.body) { 113 | return response.status === 401 ? 'fail' : 'error'; 114 | } 115 | localStorage.setItem('accessToken', response.body.access_token || ''); 116 | return 'ok'; 117 | } 118 | 119 | async logout() { 120 | await this.delete('/tokens'); 121 | localStorage.removeItem('accessToken'); 122 | } 123 | 124 | isAuthenticated() { 125 | return localStorage.getItem('accessToken') !== null; 126 | } 127 | } -------------------------------------------------------------------------------- /src/Schemas.tsx: -------------------------------------------------------------------------------- 1 | export type Pagination = { 2 | limit: number; 3 | offset: number; 4 | count: number; 5 | total: number; 6 | } 7 | 8 | export type Paginated = { 9 | pagination: Pagination; 10 | data: Array; 11 | } 12 | 13 | export type UserSchema = { 14 | id: number; 15 | url: string; 16 | username: string; 17 | email: string; 18 | has_password: boolean; 19 | avatar_url: string; 20 | about_me: string; 21 | first_seen: string; 22 | last_seen: string; 23 | posts_url: string; 24 | } 25 | 26 | export type NewUserSchema = { 27 | username: string; 28 | email: string; 29 | password: string; 30 | } 31 | 32 | export type UpdateUserSchema = { 33 | username: string; 34 | email: string; 35 | about_me: string; 36 | } 37 | 38 | export type PostSchema = { 39 | id: number; 40 | url: string; 41 | text: string; 42 | timestamp: string; 43 | author: UserSchema; 44 | } 45 | 46 | export type NewPostSchema = { 47 | text: string; 48 | } 49 | 50 | export type PasswordResetRequestSchema = { 51 | email: string; 52 | } 53 | 54 | export type PasswordResetSchema = { 55 | token: string; 56 | new_password: string; 57 | } 58 | 59 | export type PasswordChangeSchema = { 60 | old_password: string; 61 | password: string; 62 | } -------------------------------------------------------------------------------- /src/components/Body.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Container from 'react-bootstrap/Container'; 3 | import Stack from 'react-bootstrap/Stack'; 4 | import Sidebar from './Sidebar'; 5 | import FlashMessage from './FlashMessage'; 6 | 7 | type BodyProps = { 8 | sidebar?: boolean; 9 | } 10 | 11 | export default function Body({ sidebar, children }: React.PropsWithChildren) { 12 | return ( 13 | 14 | 15 | {sidebar && } 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | ); 23 | } -------------------------------------------------------------------------------- /src/components/FlashMessage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import Alert from 'react-bootstrap/Alert'; 3 | import Collapse from 'react-bootstrap/Collapse'; 4 | import { FlashContext, FlashContextType } from '../contexts/FlashProvider'; 5 | 6 | export default function FlashMessage() { 7 | const { flashMessage, visible, hideFlash } = useContext(FlashContext) as FlashContextType; 8 | 9 | return ( 10 | 11 |
12 | 14 | {flashMessage.message} 15 | 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from 'react-bootstrap/Navbar'; 2 | import Container from 'react-bootstrap/Container'; 3 | import Nav from 'react-bootstrap/Nav'; 4 | import NavDropdown from 'react-bootstrap/NavDropdown'; 5 | import Image from 'react-bootstrap/Image'; 6 | import Spinner from 'react-bootstrap/Spinner'; 7 | import { NavLink } from 'react-router-dom'; 8 | import { useUser } from '../contexts/UserProvider'; 9 | 10 | export default function Header() { 11 | const { user, logout } = useUser(); 12 | 13 | return ( 14 | 15 | 16 | Microblog 17 | 43 | 44 | 45 | ); 46 | } -------------------------------------------------------------------------------- /src/components/InputField.tsx: -------------------------------------------------------------------------------- 1 | import Form from 'react-bootstrap/Form'; 2 | 3 | type InputFieldProps = { 4 | name: string; 5 | label?: string; 6 | type?: string; 7 | placeholder?: string; 8 | error?: string; 9 | fieldRef?: React.RefObject; 10 | } 11 | 12 | export default function InputField( 13 | { name, label, type, placeholder, error, fieldRef }: InputFieldProps 14 | ) { 15 | return ( 16 | 17 | {label && {label}} 18 | 23 | {error} 24 | 25 | ); 26 | } -------------------------------------------------------------------------------- /src/components/More.tsx: -------------------------------------------------------------------------------- 1 | import Button from 'react-bootstrap/Button'; 2 | import { Pagination } from '../Schemas'; 3 | 4 | type MoreProps = { 5 | pagination: Pagination; 6 | loadNextPage: () => void; 7 | } 8 | 9 | export default function More({ pagination, loadNextPage }: MoreProps) { 10 | let thereAreMore = false; 11 | if (pagination) { 12 | const { offset, count, total } = pagination; 13 | thereAreMore = offset + count < total; 14 | } 15 | 16 | return ( 17 |
18 | {thereAreMore && 19 | 22 | } 23 |
24 | ); 25 | } -------------------------------------------------------------------------------- /src/components/Post.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import Post from './Post'; 4 | 5 | test('it renders all the components of the post', () => { 6 | const timestampUTC = '2020-01-01T00:00:00.000Z'; 7 | const post = { 8 | id: 1, 9 | url: 'https://example.com/post/1', 10 | text: 'hello', 11 | author: { 12 | id: 2, 13 | url: 'https://example.com/user/2', 14 | username: 'susan', 15 | email: 'susan@example.com', 16 | avatar_url: 'https://example.com/avatar/susan', 17 | has_password: true, 18 | about_me: 'I am Susan', 19 | first_seen: 'today', 20 | last_seen: 'today', 21 | posts_url: 'https://example.com/user/2/posts', 22 | }, 23 | timestamp: timestampUTC, 24 | }; 25 | 26 | render( 27 | 28 | 29 | 30 | ); 31 | 32 | const message = screen.getByText('hello'); 33 | const authorLink = screen.getByText('susan'); 34 | const avatar = screen.getByAltText('susan'); 35 | const timestamp = screen.getByText(/.* ago$/); 36 | 37 | expect(message).toBeInTheDocument(); 38 | expect(authorLink).toBeInTheDocument(); 39 | expect(authorLink).toHaveAttribute('href', '/user/susan'); 40 | expect(avatar).toBeInTheDocument(); 41 | expect(avatar).toHaveAttribute('src', 'https://example.com/avatar/susan&s=48'); 42 | expect(timestamp).toBeInTheDocument(); 43 | expect(timestamp).toHaveAttribute( 44 | 'title', new Date(Date.parse(timestampUTC)).toString()); 45 | }); -------------------------------------------------------------------------------- /src/components/Post.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import Stack from 'react-bootstrap/Stack'; 3 | import Image from 'react-bootstrap/Image'; 4 | import { Link } from 'react-router-dom'; 5 | import TimeAgo from './TimeAgo'; 6 | import { PostSchema } from '../Schemas'; 7 | 8 | type PostProps = { 9 | post: PostSchema; 10 | } 11 | 12 | export default memo(function Post({ post }: PostProps) { 13 | return ( 14 | 15 | {post.author.username} 17 |
18 |

19 | 20 | {post.author.username} 21 | 22 |  —  23 | : 24 |

25 |

{post.text}

26 |
27 |
28 | ); 29 | }); -------------------------------------------------------------------------------- /src/components/Posts.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import Spinner from 'react-bootstrap/Spinner'; 3 | import { useApi } from '../contexts/ApiProvider'; 4 | import Write from './Write'; 5 | import Post from './Post'; 6 | import More from './More'; 7 | import { Pagination, Paginated, PostSchema } from '../Schemas'; 8 | 9 | type PostsProps = { 10 | content?: string; 11 | write?: boolean; 12 | } 13 | 14 | export default function Posts({ content, write }: PostsProps) { 15 | const [posts, setPosts] = useState | null | undefined>(); 16 | 17 | const [pagination, setPagination] = useState(); 18 | const api = useApi(); 19 | 20 | let url = ''; 21 | switch (content) { 22 | case 'feed': 23 | case undefined: 24 | url = '/feed'; 25 | break; 26 | case 'explore': 27 | url = '/posts'; 28 | break 29 | default: 30 | url = `/users/${content}/posts`; 31 | break; 32 | } 33 | 34 | useEffect(() => { 35 | (async () => { 36 | const response = await api.get>(url); 37 | if (response.ok && response.body) { 38 | setPosts(response.body.data); 39 | setPagination(response.body.pagination); 40 | } 41 | else { 42 | setPosts(null); 43 | } 44 | })(); 45 | }, [api, url]); 46 | 47 | const loadNextPage = async () => { 48 | const oldPosts = posts || []; 49 | const response = await api.get>(url, { 50 | after: oldPosts[oldPosts.length - 1].timestamp 51 | }); 52 | if (response.ok && response.body) { 53 | setPosts([...oldPosts, ...response.body.data]); 54 | setPagination(response.body.pagination); 55 | } 56 | }; 57 | 58 | const showPost = (newPost: PostSchema) => { 59 | const oldPosts = posts || []; 60 | setPosts([newPost, ...oldPosts]); 61 | }; 62 | 63 | return ( 64 | <> 65 | {write && } 66 | {posts === undefined ? 67 | 68 | : 69 | <> 70 | {posts === null ? 71 |

Could not retrieve blog posts.

72 | : 73 | <> 74 | {posts.length === 0 ? 75 |

There are no blog posts.

76 | : 77 | posts.map(post => ) 78 | } 79 | 80 | } 81 | 82 | } 83 | {pagination && } 84 | 85 | ); 86 | } -------------------------------------------------------------------------------- /src/components/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useLocation, Navigate } from 'react-router-dom'; 3 | import { useUser } from '../contexts/UserProvider'; 4 | 5 | export default function PrivateRoute({ children }: React.PropsWithChildren<{}>) { 6 | const { user } = useUser(); 7 | const location = useLocation(); 8 | 9 | if (user === undefined) { 10 | return null; 11 | } 12 | else if (user) { 13 | return <>{children}; 14 | } 15 | 16 | const url = location.pathname + location.search + location.hash; 17 | return 18 | } -------------------------------------------------------------------------------- /src/components/PublicRoute.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Navigate } from 'react-router-dom'; 3 | import { useUser } from '../contexts/UserProvider'; 4 | 5 | export default function PublicRoute({ children }: React.PropsWithChildren<{}>) { 6 | const { user } = useUser(); 7 | 8 | if (user === undefined) { 9 | return null; 10 | } 11 | else if (user) { 12 | return 13 | } 14 | else { 15 | return <>{children}; 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "react-bootstrap/Navbar"; 2 | import Nav from "react-bootstrap/Nav"; 3 | import { NavLink } from 'react-router-dom'; 4 | 5 | export default function Sidebar() { 6 | return ( 7 | 8 | 9 | Feed 10 | 11 | 12 | Explore 13 | 14 | 15 | ); 16 | } -------------------------------------------------------------------------------- /src/components/TimeAgo.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | const secondsTable: Array<[Intl.RelativeTimeFormatUnit, number]> = [ 4 | ['years', 60 * 60 * 24 * 365], 5 | ['months', 60 * 60 * 24 * 30], 6 | ['weeks', 60 * 60 * 24 * 7], 7 | ['days', 60 * 60 * 24], 8 | ['hours', 60 * 60], 9 | ['minutes', 60], 10 | ]; 11 | const rtf = new Intl.RelativeTimeFormat(undefined, {numeric: 'auto'}); 12 | 13 | type TimeAgoType = { 14 | bestTime: number; 15 | bestUnit: Intl.RelativeTimeFormatUnit; 16 | bestInterval: number; 17 | } 18 | 19 | function getTimeAgo(date: Date): TimeAgoType { 20 | const seconds = Math.round((date.getTime() - new Date().getTime()) / 1000); 21 | const absSeconds = Math.abs(seconds); 22 | let bestUnit: Intl.RelativeTimeFormatUnit = 'seconds', bestTime = 0, bestInterval = 0; 23 | for (let [unit, unitSeconds] of secondsTable) { 24 | if (absSeconds >= unitSeconds) { 25 | bestUnit = unit; 26 | bestTime = Math.round(seconds / unitSeconds); 27 | bestInterval = Math.min(unitSeconds / 2, 60 * 60 * 24); 28 | break; 29 | } 30 | }; 31 | if (!bestUnit) { 32 | bestUnit = 'seconds'; 33 | bestTime = Math.floor(seconds / 10) * 10; 34 | bestInterval = 10; 35 | } 36 | return {bestTime, bestUnit, bestInterval}; 37 | } 38 | 39 | type TimeAgoProps = { 40 | isoDate: string; 41 | } 42 | 43 | export default function TimeAgo({ isoDate }: TimeAgoProps) { 44 | const date = new Date(Date.parse(isoDate)); 45 | const { bestTime: time, bestUnit: unit, bestInterval: interval } = getTimeAgo(date); 46 | const [, setUpdate] = useState(0); 47 | 48 | useEffect(() => { 49 | const timerId = setInterval( 50 | () => setUpdate(update => update + 1), 51 | interval * 1000 52 | ); 53 | return () => clearInterval(timerId); 54 | }, [interval]); 55 | 56 | return ( 57 | {rtf.format(time, unit)} 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Write.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import Stack from "react-bootstrap/Stack"; 3 | import Image from "react-bootstrap/Image"; 4 | import Form from 'react-bootstrap/Form'; 5 | import InputField from './InputField'; 6 | import { useApi } from '../contexts/ApiProvider'; 7 | import { useUser } from '../contexts/UserProvider'; 8 | import { NewPostSchema, PostSchema } from '../Schemas'; 9 | 10 | type WriteProps = { 11 | showPost: (post: any) => void; 12 | } 13 | 14 | type FormErrorsType = { 15 | text?: string; 16 | } 17 | 18 | export default function Write({ showPost }: WriteProps) { 19 | const [formErrors, setFormErrors] = useState({}); 20 | const textField = useRef(null); 21 | const api = useApi(); 22 | const { user } = useUser(); 23 | 24 | useEffect(() => { 25 | if (textField.current) { 26 | textField.current.focus(); 27 | } 28 | }, []); 29 | 30 | const onSubmit = async (ev: React.FormEvent) => { 31 | ev.preventDefault(); 32 | const response = await api.post("/posts", { 33 | text: textField.current?.value || '', 34 | }); 35 | if (response.ok) { 36 | showPost(response.body); 37 | if (textField.current) { 38 | textField.current.value = ''; 39 | } 40 | } 41 | else { 42 | if (response.errors) { 43 | setFormErrors(response.errors.json); 44 | } 45 | } 46 | }; 47 | 48 | return ( 49 | 50 | {user && 51 | 55 | } 56 |
57 | 60 | 61 |
62 | ); 63 | } -------------------------------------------------------------------------------- /src/contexts/ApiProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useCallback, useMemo } from 'react'; 2 | import MicroblogApiClient from '../MicroblogApiClient'; 3 | import { useFlash } from './FlashProvider'; 4 | 5 | const ApiContext = createContext(null); 6 | 7 | export default function ApiProvider({ children }: React.PropsWithChildren<{}>) { 8 | const flash = useFlash(); 9 | 10 | const onError = useCallback(() => { 11 | flash('An unexpected error has occurred. Please try again later.', 'danger'); 12 | }, [flash]); 13 | 14 | const api = useMemo(() => new MicroblogApiClient(onError), [onError]); 15 | 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | 23 | export function useApi() { 24 | return useContext(ApiContext) as MicroblogApiClient; 25 | } -------------------------------------------------------------------------------- /src/contexts/FlashProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from '@testing-library/react'; 2 | import { useEffect } from 'react'; 3 | import FlashProvider from './FlashProvider'; 4 | import { useFlash } from './FlashProvider'; 5 | import FlashMessage from '../components/FlashMessage'; 6 | 7 | beforeEach(() => { 8 | jest.useFakeTimers(); 9 | }); 10 | 11 | afterEach(() => { 12 | jest.useRealTimers() 13 | }); 14 | 15 | test('flashes a message', () => { 16 | const Test = () => { 17 | const flash = useFlash(); 18 | useEffect(() => { 19 | flash('foo', 'danger'); 20 | }, []); 21 | return null; 22 | }; 23 | 24 | render( 25 | 26 | 27 | 28 | 29 | ); 30 | 31 | const alert = screen.getByRole('alert'); 32 | 33 | expect(alert).toHaveTextContent('foo'); 34 | expect(alert).toHaveClass('alert-danger'); 35 | expect(alert).toHaveAttribute('data-visible', 'true'); 36 | 37 | act(() => jest.runAllTimers()); 38 | expect(alert).toHaveAttribute('data-visible', 'false'); 39 | }); -------------------------------------------------------------------------------- /src/contexts/FlashProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useCallback } from 'react'; 2 | 3 | export type FlashContextType = { 4 | flash: (message: string | JSX.Element, type: string, duration?: number) => void; 5 | hideFlash: () => void; 6 | flashMessage: { message?: string | JSX.Element, type?: string }; 7 | visible: boolean; 8 | } 9 | 10 | export const FlashContext = createContext(null); 11 | let flashTimer: number | undefined; 12 | 13 | export default function FlashProvider({ children }: React.PropsWithChildren<{}>) { 14 | const [flashMessage, setFlashMessage] = useState<{message?: string | JSX.Element, type?: string}>({}); 15 | const [visible, setVisible] = useState(false); 16 | 17 | const hideFlash = useCallback(() => { 18 | setVisible(false); 19 | }, []); 20 | 21 | const flash = useCallback((message: string | JSX.Element, type: string, duration = 10) => { 22 | if (flashTimer) { 23 | window.clearTimeout(flashTimer); 24 | flashTimer = undefined; 25 | } 26 | setFlashMessage({message, type}); 27 | setVisible(true); 28 | if (duration) { 29 | flashTimer = window.setTimeout(hideFlash, duration * 1000); 30 | } 31 | }, [hideFlash]); 32 | 33 | return ( 34 | 35 | {children} 36 | 37 | ); 38 | } 39 | 40 | export function useFlash() { 41 | return (useContext(FlashContext) as FlashContextType).flash; 42 | } -------------------------------------------------------------------------------- /src/contexts/UserProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event' 3 | import { useState, useEffect } from 'react'; 4 | import FlashProvider from './FlashProvider'; 5 | import ApiProvider from './ApiProvider'; 6 | import UserProvider from './UserProvider'; 7 | import { useUser } from './UserProvider'; 8 | 9 | const realFetch = global.fetch; 10 | 11 | beforeEach(() => { 12 | global.fetch = jest.fn(); 13 | }); 14 | 15 | afterEach(() => { 16 | global.fetch = realFetch; 17 | localStorage.clear(); 18 | }); 19 | 20 | test('logs user in', async () => { 21 | const urls: string[] = []; 22 | 23 | const mockedFetch = global.fetch as jest.MockedFunction; 24 | mockedFetch 25 | .mockImplementationOnce(url => { 26 | urls.push(url.toString()); 27 | return Promise.resolve({ 28 | status: 200, 29 | ok: true, 30 | json: () => Promise.resolve({access_token: '123'}), 31 | } as Response); 32 | }) 33 | .mockImplementationOnce(url => { 34 | urls.push(url.toString()); 35 | return Promise.resolve({ 36 | status: 200, 37 | ok: true, 38 | json: () => Promise.resolve({username: 'susan'}), 39 | } as Response); 40 | }); 41 | 42 | const Test = () => { 43 | const { login, user } = useUser(); 44 | useEffect(() => { 45 | (async () => await login('username', 'password'))(); 46 | }, [login]); 47 | return user ?

{user.username}

: null; 48 | }; 49 | 50 | render( 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | 60 | const element = await screen.findByText('susan'); 61 | expect(element).toBeInTheDocument(); 62 | expect(global.fetch).toHaveBeenCalledTimes(2); 63 | expect(urls).toHaveLength(2); 64 | expect(urls[0]).toMatch(/^http.*\/api\/tokens$/); 65 | expect(urls[1]).toMatch(/^http.*\/api\/me$/); 66 | }); 67 | 68 | test('logs user in with bad credentials', async () => { 69 | const urls: string[] = []; 70 | 71 | const mockedFetch = global.fetch as jest.MockedFunction; 72 | mockedFetch 73 | .mockImplementationOnce(url => { 74 | urls.push(url.toString()); 75 | return Promise.resolve({ 76 | status: 401, 77 | ok: false, 78 | json: () => Promise.resolve({}), 79 | } as Response); 80 | }); 81 | 82 | const Test = () => { 83 | const [result, setResult] = useState<'ok' | 'error' | 'fail' | null>(null); 84 | const { login, user } = useUser(); 85 | useEffect(() => { 86 | (async () => { 87 | setResult(await login('username', 'password')); 88 | })(); 89 | }, [login]); 90 | return <>{result}; 91 | }; 92 | 93 | render( 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | ); 102 | 103 | const element = await screen.findByText('fail'); 104 | expect(element).toBeInTheDocument(); 105 | expect(global.fetch).toHaveBeenCalledTimes(1); 106 | expect(urls).toHaveLength(1); 107 | expect(urls[0]).toMatch(/^http.*\/api\/tokens$/); 108 | }); 109 | 110 | test('logs user out', async () => { 111 | const mockedFetch = global.fetch as jest.MockedFunction; 112 | mockedFetch 113 | .mockImplementationOnce(url => { 114 | return Promise.resolve({ 115 | status: 200, 116 | ok: true, 117 | json: () => Promise.resolve({username: 'susan'}), 118 | } as Response); 119 | }) 120 | .mockImplementationOnce(url => { 121 | return Promise.resolve({ 122 | status: 204, 123 | ok: true, 124 | json: () => Promise.resolve({}), 125 | } as Response); 126 | }); 127 | 128 | localStorage.setItem('accessToken', '123'); 129 | 130 | const Test = () => { 131 | const { user, logout } = useUser(); 132 | if (user) { 133 | return ( 134 | <> 135 |

{user.username}

136 | 137 | 138 | ); 139 | } 140 | else if (user === null) { 141 | return

logged out

; 142 | } 143 | else { 144 | return null; 145 | } 146 | }; 147 | 148 | render( 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | ); 157 | 158 | const element = await screen.findByText('susan'); 159 | const button = await screen.findByRole('button'); 160 | expect(element).toBeInTheDocument(); 161 | expect(button).toBeInTheDocument(); 162 | 163 | userEvent.click(button); 164 | const element2 = await screen.findByText('logged out'); 165 | expect(element2).toBeInTheDocument(); 166 | expect(localStorage.getItem('accessToken')).toBeNull(); 167 | }); -------------------------------------------------------------------------------- /src/contexts/UserProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; 2 | import { useApi } from './ApiProvider'; 3 | import { UserSchema } from '../Schemas'; 4 | 5 | type UserContextType = { 6 | user: UserSchema | null | undefined; 7 | setUser: (user: UserSchema | null | undefined) => void; 8 | login: (username: string, password: string) => Promise<'ok' | 'fail' | 'error'>; 9 | logout: () => Promise; 10 | } 11 | 12 | const UserContext = createContext(null); 13 | 14 | export default function UserProvider({ children }: React.PropsWithChildren<{}>) { 15 | const [user, setUser] = useState(); 16 | const api = useApi(); 17 | 18 | useEffect(() => { 19 | (async () => { 20 | if (api.isAuthenticated()) { 21 | const response = await api.get('/me'); 22 | setUser(response.ok ? response.body : null); 23 | } 24 | else { 25 | setUser(null); 26 | } 27 | })(); 28 | }, [api]); 29 | 30 | const login = useCallback(async (username: string, password: string) => { 31 | const result = await api.login(username, password); 32 | if (result === 'ok') { 33 | const response = await api.get('/me'); 34 | setUser(response.ok ? response.body : null); 35 | } 36 | return result; 37 | }, [api]); 38 | 39 | const logout = useCallback(async () => { 40 | await api.logout(); 41 | setUser(null); 42 | }, [api]); 43 | 44 | return ( 45 | 46 | {children} 47 | 48 | ); 49 | } 50 | 51 | export function useUser() { 52 | return useContext(UserContext) as UserContextType; 53 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .App { 2 | padding: 0; 3 | } 4 | 5 | .Header { 6 | border-bottom: 1px solid #ddd; 7 | } 8 | 9 | .Sidebar { 10 | width: 120px; 11 | margin: 5px; 12 | position: sticky; 13 | top: 62px; 14 | align-self: flex-start; 15 | align-items: start; 16 | } 17 | 18 | .Sidebar .nav-item { 19 | width: 100%; 20 | } 21 | 22 | .Sidebar a, .dropdown-item, .dropdown-item.active { 23 | color: #444; 24 | } 25 | 26 | .Sidebar a:hover { 27 | background-color: #eee; 28 | } 29 | 30 | .Sidebar a:visited { 31 | color: #444; 32 | } 33 | 34 | .Sidebar .nav-item .active, .dropdown-item.active { 35 | background-color: #def; 36 | } 37 | 38 | .Content { 39 | margin-top: 10px; 40 | } 41 | 42 | .Post { 43 | align-items: start; 44 | padding-top: 5px; 45 | border-bottom: 1px solid #eee; 46 | } 47 | 48 | .Post:hover { 49 | background-color: #f8f8f8; 50 | } 51 | 52 | .Post a { 53 | color: #14c; 54 | text-decoration: none; 55 | } 56 | 57 | .Post a:visited { 58 | color: #14c; 59 | } 60 | 61 | .More { 62 | margin-top: 10px; 63 | margin-bottom: 10px; 64 | text-align: right; 65 | } 66 | 67 | .InputField { 68 | margin-top: 15px; 69 | margin-bottom: 15px; 70 | } 71 | 72 | .Write { 73 | margin-bottom: 10px; 74 | padding: 30px 0px 40px 0px; 75 | border-bottom: 1px solid #eee; 76 | } 77 | 78 | .Write form { 79 | width: 100%; 80 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import 'bootstrap/dist/css/bootstrap.min.css'; 4 | import './index.css'; 5 | import App from './App'; 6 | import reportWebVitals from './reportWebVitals'; 7 | 8 | const root = ReactDOM.createRoot( 9 | document.getElementById('root') as HTMLElement 10 | ); 11 | root.render( 12 | 13 | 14 | 15 | ); 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals(); 21 | -------------------------------------------------------------------------------- /src/pages/ChangePasswordPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import Body from '../components/Body'; 6 | import InputField from '../components/InputField'; 7 | import { useApi } from '../contexts/ApiProvider'; 8 | import { useFlash } from '../contexts/FlashProvider'; 9 | import { PasswordChangeSchema } from '../Schemas'; 10 | 11 | type FormErrorsType = { 12 | old_password?: string; 13 | password?: string; 14 | password2?: string; 15 | } 16 | 17 | export default function EditUserPage() { 18 | const [formErrors, setFormErrors] = useState({}); 19 | const oldPasswordField = useRef(null); 20 | const passwordField = useRef(null); 21 | const password2Field = useRef(null); 22 | const navigate = useNavigate(); 23 | const api = useApi(); 24 | const flash = useFlash(); 25 | 26 | useEffect(() => { 27 | if (oldPasswordField.current) { 28 | oldPasswordField.current.focus(); 29 | } 30 | }, []); 31 | 32 | const onSubmit = async (event: React.FormEvent) => { 33 | event.preventDefault(); 34 | if (passwordField.current?.value !== password2Field.current?.value) { 35 | setFormErrors({password2: "New passwords don't match"}); 36 | } 37 | else { 38 | const response = await api.put('/me', { 39 | old_password: oldPasswordField.current?.value || '', 40 | password: passwordField.current?.value || '', 41 | }); 42 | if (response.ok) { 43 | setFormErrors({}); 44 | flash('Your password has been updated.', 'success'); 45 | navigate('/me'); 46 | } 47 | else if (response.errors) { 48 | setFormErrors(response.errors.json); 49 | } 50 | } 51 | }; 52 | 53 | return ( 54 | 55 |

Change Your Password

56 |
57 | 60 | 63 | 66 | 67 | 68 | 69 | ); 70 | } -------------------------------------------------------------------------------- /src/pages/EditUserPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import Body from '../components/Body'; 6 | import InputField from '../components/InputField'; 7 | import { useApi } from '../contexts/ApiProvider'; 8 | import { useUser } from '../contexts/UserProvider'; 9 | import { useFlash } from '../contexts/FlashProvider'; 10 | import { UpdateUserSchema, UserSchema } from '../Schemas'; 11 | 12 | type FormErrorsType = { 13 | username?: string; 14 | email?: string; 15 | about_me?: string; 16 | } 17 | 18 | export default function EditUserPage() { 19 | const [formErrors, setFormErrors] = useState({}); 20 | const usernameField = useRef(null); 21 | const emailField = useRef(null); 22 | const aboutMeField = useRef(null); 23 | const api = useApi(); 24 | const { user, setUser } = useUser(); 25 | const flash = useFlash(); 26 | const navigate = useNavigate(); 27 | 28 | useEffect(() => { 29 | if (usernameField.current) { 30 | usernameField.current.value = user?.username || ''; 31 | usernameField.current.focus(); 32 | } 33 | if (emailField.current) { 34 | emailField.current.value = user?.email || ''; 35 | } 36 | if (aboutMeField.current) { 37 | aboutMeField.current.value = user?.about_me || ''; 38 | } 39 | }, [user]); 40 | 41 | const onSubmit = async (event: React.FormEvent) => { 42 | event.preventDefault(); 43 | const response = await api.put('/me', { 44 | username: usernameField.current?.value || '', 45 | email: emailField.current?.value || '', 46 | about_me: aboutMeField.current?.value || '', 47 | }); 48 | if (response.ok) { 49 | setFormErrors({}); 50 | setUser(response.body); 51 | flash('Your profile has been updated.', 'success'); 52 | navigate('/user/' + response.body?.username); 53 | } 54 | else if (response.errors) { 55 | setFormErrors(response.errors.json); 56 | } 57 | }; 58 | 59 | return ( 60 | 61 |
62 | 65 | 68 | 71 | 72 | 73 | 74 | ); 75 | } -------------------------------------------------------------------------------- /src/pages/ExplorePage.tsx: -------------------------------------------------------------------------------- 1 | import Body from '../components/Body'; 2 | import Posts from '../components/Posts'; 3 | 4 | export default function ExplorePage() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } -------------------------------------------------------------------------------- /src/pages/FeedPage.tsx: -------------------------------------------------------------------------------- 1 | import Body from '../components/Body'; 2 | import Posts from '../components/Posts'; 3 | 4 | export default function FeedPage() { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | } -------------------------------------------------------------------------------- /src/pages/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { Link, useNavigate, useLocation } from 'react-router-dom'; 3 | import Form from 'react-bootstrap/Form'; 4 | import Button from 'react-bootstrap/Button'; 5 | import Body from '../components/Body'; 6 | import InputField from '../components/InputField'; 7 | import { useUser } from '../contexts/UserProvider'; 8 | import { useFlash } from '../contexts/FlashProvider'; 9 | 10 | type FormErrorsType = { 11 | username?: string; 12 | password?: string; 13 | } 14 | 15 | export default function LoginPage() { 16 | const [formErrors, setFormErrors] = useState({}); 17 | const usernameField = useRef(null); 18 | const passwordField = useRef(null); 19 | const { login } = useUser(); 20 | const flash = useFlash(); 21 | const navigate = useNavigate(); 22 | const location = useLocation(); 23 | 24 | useEffect(() => { 25 | if (usernameField.current) { 26 | usernameField.current.focus(); 27 | } 28 | }, []); 29 | 30 | const onSubmit = async (ev: React.FormEvent) => { 31 | ev.preventDefault(); 32 | const username = usernameField.current ? usernameField.current.value : ''; 33 | const password = passwordField.current ? passwordField.current.value : ''; 34 | 35 | const errors: FormErrorsType = {}; 36 | if (!username) { 37 | errors.username = 'Username must not be empty.'; 38 | } 39 | if (!password) { 40 | errors.password = 'Password must not be empty.'; 41 | } 42 | setFormErrors(errors); 43 | if (Object.keys(errors).length > 0) { 44 | return; 45 | } 46 | 47 | const result = await login(username, password) 48 | if (result === 'fail') { 49 | flash('Invalid username or password', 'danger'); 50 | } 51 | else if (result === 'ok') { 52 | let next = '/'; 53 | if (location.state && location.state.next) { 54 | next = location.state.next; 55 | } 56 | navigate(next); 57 | } 58 | }; 59 | 60 | return ( 61 | 62 |

Login

63 |
64 | 67 | 70 | 71 | 72 |
73 |

Forgot your password? You can reset it.

74 |

Don't have an account? Register here!

75 | 76 | ); 77 | } -------------------------------------------------------------------------------- /src/pages/RegistrationPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import Form from 'react-bootstrap/Form'; 4 | import Button from 'react-bootstrap/Button'; 5 | import Body from '../components/Body'; 6 | import InputField from '../components/InputField'; 7 | import { useApi } from '../contexts/ApiProvider'; 8 | import { useFlash } from '../contexts/FlashProvider'; 9 | import { NewUserSchema, UserSchema } from '../Schemas'; 10 | 11 | type FormErrorsType = { 12 | username?: string; 13 | email?: string; 14 | password?: string; 15 | password2?: string; 16 | } 17 | 18 | export default function RegistrationPage() { 19 | const [formErrors, setFormErrors] = useState({}); 20 | const usernameField = useRef(null); 21 | const emailField = useRef(null); 22 | const passwordField = useRef(null); 23 | const password2Field = useRef(null); 24 | const navigate = useNavigate(); 25 | const api = useApi(); 26 | const flash = useFlash(); 27 | 28 | useEffect(() => { 29 | if (usernameField.current) { 30 | usernameField.current.focus(); 31 | } 32 | }, []); 33 | 34 | const onSubmit = async (event: React.FormEvent) => { 35 | event.preventDefault(); 36 | if (passwordField.current?.value !== password2Field.current?.value) { 37 | setFormErrors({password2: "Passwords don't match"}); 38 | console.log('2. set'); 39 | } 40 | else { 41 | const data = await api.post('/users', { 42 | username: usernameField.current ? usernameField.current.value : '', 43 | email: emailField.current ? emailField.current.value : '', 44 | password: passwordField.current ? passwordField.current.value : '', 45 | }); 46 | if (!data.ok && data.errors) { 47 | console.log('1. set', data.errors); 48 | setFormErrors(data.errors.json); 49 | } 50 | else { 51 | setFormErrors({}); 52 | flash('You have successfully registered!', 'success'); 53 | navigate('/login'); 54 | } 55 | } 56 | }; 57 | 58 | return ( 59 | 60 |

Register

61 |
62 | 65 | 68 | 71 | 74 | 75 | 76 | 77 | ); 78 | } -------------------------------------------------------------------------------- /src/pages/ResetPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import { useNavigate, useLocation } from 'react-router-dom'; 5 | import Body from '../components/Body'; 6 | import InputField from '../components/InputField'; 7 | import { useApi } from '../contexts/ApiProvider'; 8 | import { useFlash } from '../contexts/FlashProvider'; 9 | import { PasswordResetSchema } from '../Schemas'; 10 | 11 | type FormErrorsType = { 12 | password?: string; 13 | password2?: string; 14 | } 15 | 16 | export default function ResetPage() { 17 | const [formErrors, setFormErrors] = useState({}); 18 | const passwordField = useRef(null); 19 | const password2Field = useRef(null); 20 | const navigate = useNavigate(); 21 | const { search } = useLocation(); 22 | const api = useApi(); 23 | const flash = useFlash(); 24 | const token = new URLSearchParams(search).get('token'); 25 | 26 | useEffect(() => { 27 | if (!token) { 28 | navigate('/'); 29 | } 30 | else { 31 | if (passwordField.current) { 32 | passwordField.current.focus(); 33 | } 34 | } 35 | }, [token, navigate]); 36 | 37 | const onSubmit = async (event: React.FormEvent) => { 38 | event.preventDefault(); 39 | if (passwordField.current?.value !== password2Field.current?.value) { 40 | setFormErrors({password2: "New passwords don't match"}); 41 | } 42 | else { 43 | const response = await api.put('/tokens/reset', { 44 | token: token || '', 45 | new_password: passwordField.current?.value || '', 46 | }); 47 | if (response.ok) { 48 | setFormErrors({}); 49 | flash('Your password has been reset.', 'success'); 50 | navigate('/login'); 51 | } 52 | else { 53 | if (response.errors?.json?.new_password) { 54 | setFormErrors(response.errors.json); 55 | } 56 | else { 57 | flash('Password could not be reset. Please try again.', 'danger'); 58 | navigate('/reset-request'); 59 | } 60 | } 61 | } 62 | }; 63 | 64 | return ( 65 | 66 |

Reset Your Password

67 |
68 | 71 | 74 | 75 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/pages/ResetRequestPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | import Form from 'react-bootstrap/Form'; 3 | import Button from 'react-bootstrap/Button'; 4 | import Body from '../components/Body'; 5 | import InputField from '../components/InputField'; 6 | import { useApi } from '../contexts/ApiProvider'; 7 | import { useFlash } from '../contexts/FlashProvider'; 8 | import { PasswordResetRequestSchema } from '../Schemas'; 9 | 10 | type FormErrorsType = { 11 | email?: string; 12 | } 13 | 14 | export default function ResetRequestPage() { 15 | const [formErrors, setFormErrors] = useState({}); 16 | const emailField = useRef(null); 17 | const api = useApi(); 18 | const flash = useFlash(); 19 | 20 | useEffect(() => { 21 | if (emailField.current) { 22 | emailField.current.focus(); 23 | } 24 | }, []); 25 | 26 | const onSubmit = async (event: React.FormEvent) => { 27 | event.preventDefault(); 28 | const response = await api.post('/tokens/reset', { 29 | email: emailField.current?.value || '', 30 | }); 31 | if (!response.ok && response.errors) { 32 | setFormErrors(response.errors.json); 33 | } 34 | else { 35 | if (emailField.current) { 36 | emailField.current.value = ''; 37 | } 38 | setFormErrors({}); 39 | flash( 40 | 'You will receive an email with instructions ' + 41 | 'to reset your password.', 'info' 42 | ); 43 | } 44 | }; 45 | 46 | return ( 47 | 48 |

Reset Your Password

49 |
50 | 53 | 54 | 55 | 56 | ); 57 | } -------------------------------------------------------------------------------- /src/pages/UserPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import Stack from 'react-bootstrap/Stack'; 3 | import Image from 'react-bootstrap/Image'; 4 | import Spinner from 'react-bootstrap/Spinner'; 5 | import Button from 'react-bootstrap/Button'; 6 | import { useParams, useNavigate } from 'react-router-dom'; 7 | import Body from '../components/Body'; 8 | import Posts from '../components/Posts'; 9 | import TimeAgo from '../components/TimeAgo'; 10 | import { useApi } from '../contexts/ApiProvider'; 11 | import { useUser } from '../contexts/UserProvider'; 12 | import { useFlash } from '../contexts/FlashProvider'; 13 | import { UserSchema } from '../Schemas'; 14 | 15 | export default function UserPage() { 16 | const [user, setUser] = useState(); 17 | const [isFollower, setIsFollower] = useState(null); 18 | const { username } = useParams(); 19 | const navigate = useNavigate(); 20 | const api = useApi(); 21 | const { user: loggedInUser } = useUser(); 22 | const flash = useFlash(); 23 | 24 | useEffect(() => { 25 | (async () => { 26 | const response = await api.get('/users/' + username); 27 | if (response.ok && response.body) { 28 | setUser(response.body); 29 | if (response.body.username !== loggedInUser?.username) { 30 | const follower = await api.get( 31 | '/me/following/' + response.body.id); 32 | if (follower.status === 204) { 33 | setIsFollower(true); 34 | } 35 | else if (follower.status === 404) { 36 | setIsFollower(false); 37 | } 38 | } 39 | else { 40 | setIsFollower(null); 41 | } 42 | } 43 | else { 44 | setUser(undefined); 45 | } 46 | })(); 47 | }, [username, api, loggedInUser]); 48 | 49 | const edit = () => { 50 | navigate('/edit'); 51 | }; 52 | 53 | const follow = async () => { 54 | const response = await api.post('/me/following/' + user?.id); 55 | if (response.ok) { 56 | flash( 57 | <> 58 | You are now following {user?.username}. 59 | , 'success' 60 | ); 61 | setIsFollower(true); 62 | } 63 | }; 64 | 65 | const unfollow = async () => { 66 | const response = await api.delete('/me/following/' + user?.id); 67 | if (response.ok) { 68 | flash( 69 | <> 70 | You have unfollowed {user?.username}. 71 | , 'success' 72 | ); 73 | setIsFollower(false); 74 | } 75 | }; 76 | 77 | return ( 78 | 79 | {user === undefined ? 80 | 81 | : 82 | <> 83 | {user === null ? 84 |

User not found.

85 | : 86 | <> 87 | 88 | 89 |
90 |

{user.username}

91 | {user.about_me &&
{user.about_me}
} 92 |

93 | Member since: 94 |
95 | Last seen: 96 |

97 | 98 | {isFollower === null && 99 | 100 | } 101 | {isFollower === false && 102 | 105 | } 106 | {isFollower === true && 107 | 110 | } 111 |
112 |
113 | 114 | 115 | } 116 | 117 | } 118 | 119 | ); 120 | } -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 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'; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------