├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------