├── README.md ├── backend ├── .gitignore ├── README.md ├── api │ ├── __init__.py │ ├── custom_responses.py │ ├── settings.py │ ├── urls.py │ ├── views.py │ └── wsgi.py ├── initial_todos.json ├── manage.py ├── requirements.txt ├── todo │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── tests.py │ ├── urls.py │ └── views.py └── user │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ └── __init__.py │ ├── models.py │ ├── serializers.py │ ├── tests.py │ ├── urls.py │ └── views.py ├── frontend ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── Routes.js │ ├── components │ │ ├── auth │ │ │ ├── GoogleLoginButton.js │ │ │ ├── LoginForm.css │ │ │ ├── LoginForm.js │ │ │ ├── SignupForm.css │ │ │ └── SignupForm.js │ │ ├── routing │ │ │ ├── AppliedRoute.js │ │ │ ├── AuthenticatedRoute.js │ │ │ ├── Nav.js │ │ │ ├── NotFound.css │ │ │ └── NotFound.js │ │ └── todos │ │ │ ├── TodoList.css │ │ │ ├── TodoList.js │ │ │ └── TodoModal.js │ ├── containers │ │ ├── Home.css │ │ ├── Home.js │ │ ├── Login.js │ │ └── Signup.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ └── serviceWorker.js └── yarn.lock └── screenshots ├── database_schema.png ├── main.jpg ├── system_flow.jpg ├── test.gif ├── todolist.jpg ├── todolist_complete.jpg └── todolist_incomplete.jpg /README.md: -------------------------------------------------------------------------------- 1 | # django-react-todo-manager 2 | 3 | #### JWT 기반의 Django Auth REST API, Google Oauth2.0 로그인 가능한 React Client Web App 4 | 5 | 6 | system-configuration 7 | 8 | 9 |
10 | 11 |
12 | 13 | ## *Introduction* 14 | 15 | ### Summary 16 | 17 | > - Project 소개 18 | > - Todos Web Application 19 | > - Google Social Login 기능 구현 20 | > - Web App을 통하여 회원 가입 가능하고, Google 계정으로 로그인하면 서버에 자동으로 회원 가입 21 | > - JWT를 이용한 OAuth 2.0 Auth 프로토콜 기반의 인증 서버 구현 22 | > - 토큰이 유효하다면 웹 브라우저를 통해 Todo CRUD 서비스 가능 23 | > - 서버는 RESTful하게 설계되었기 때문에 HTTP 의 ruequst 이용한 CRUD 서비스도 가능 24 | > - 10분이 지나면 자동으로 토큰 만료되고 로그아웃 처리 25 | >
26 | > 27 | > - BACKEND (Djagno Authentication Server) 28 | > - Django를 이용하여 회원(User), 일과(Todo) 정보 저장용 REST API 구현 29 | > - JWT를 이용한 OAuth 2.0 Auth 프로토콜 기반으로 Authentication 및 Authorization 구현 30 | >
31 | > 32 | > - FRONTEND (React Webapp Client) 33 | > - React를 이용하여 로그인 및 Todo CRUD 서비스용 Web App 구현 34 | > - OAuth 2.0 Google API 이용하여 구글 계정으로 소셜 로그인 구현 35 | > - React Web App을 통하여 회원 가입 가능하고, Google 계정으로 로그인하면 서버에 자동 회원 가입 36 | 37 | 38 | ### Requirements 39 | 40 | > - BACKEND (Djagno Authentication Server) 41 | > - [Python 3.6](https://www.python.org/downloads/release/python-360/) 42 | > - [Django 2.2.3](https://docs.djangoproject.com/en/2.2/releases/2.2.3/) 43 | > - [Django REST Framework 3.10.1](https://www.django-rest-framework.org/) 44 | > - [Django REST Framework JWT 1.11.0](https://github.com/jpadilla/django-rest-framework-jwt) 45 | >
46 | > 47 | > - FRONTEND (React Webapp Client) 48 | > - [React 16.5.2](https://www.npmjs.com/package/react?activeTab=versions) 49 | > - [React DOM 16.5.2](https://www.npmjs.com/package/react-dom) 50 | > - [React Google Login 5.0.4](https://www.npmjs.com/package/react-google-login) 51 | > - [React Bootstrap 0.32.4](https://www.npmjs.com/package/react-bootstrap) 52 | >
53 | > 54 | > - Database 55 | > - [MySQL 5.6](https://dev.mysql.com/downloads/mysql/5.6.html) 56 | 57 | ### Backend End-points 58 | 59 | > **Resource modeling** 60 | > 61 | > - 인증(Token 발급 및 갱신) 관련 API 62 | > 63 | > | HTTP | Path | Method | Permission | 목적 | 64 | > | --- | --- | --- | --- | --- | 65 | > |**POST** |/login|CREATE| None |Google의 ID 토큰을 받아 JWT를 반환| 66 | > |**GET** |/validate|READ| Access Token |JWT를 받아 토큰을 검증하여 상태코드로 반환| 67 | > |**POST** |/refresh|CREATE| Access Token |JWT를 받아 토큰을 검증하여 새로운 토큰 반환| 68 | > 69 | > - 회원(User) 리소스 관련 API 70 | > 71 | > | HTTP | Path | Method | Permission | 목적 | 72 | > | --- | --- | --- | --- | --- | 73 | > |**GET** |/user|LIST| Access Token |모든 User 조회| 74 | > |**POST** |/user|CREATE| Access Token |하나의 User 생성| 75 | > |**GET** |/user/current|READ| Access Token |현재 접속중인 User 조회| 76 | > 77 | > - 일과(Todo) 리소스 관련 API 78 | > 79 | > | HTTP | Path | Method | Permission | 목적 | 80 | > | --- | --- | --- | --- | --- | 81 | > |**GET** |/todo|LIST| Access Token |모든 Todo 조회| 82 | > |**POST** |/todo|CREATE| Access Token |하나의 Todo 생성| 83 | > |**GET** |/todo/todo_id|READ| Access Token |하나의 Todo 조회| 84 | > |**PUT** |/todo/todo_id|UPDATE| Access Token |하나의 Todo 수정| 85 | > |**DELETE** |/todo/todo_id|DELETE| Access Token |하나의 Todo 삭제| 86 | > 87 | > **Urls** 88 | > 89 | > - `backend/api/urls.py` 90 | > ```python 91 | > from django.contrib import admin 92 | > from django.urls import path, include 93 | > from rest_framework_jwt.views import obtain_jwt_token, verify_jwt_token, refresh_jwt_token 94 | > from .views import validate_jwt_token 95 | > 96 | > urlpatterns = [ 97 | > 98 | > path('validate/', validate_jwt_token), 99 | > path('login/', obtain_jwt_token), 100 | > 101 | > path('verify/', verify_jwt_token), 102 | > path('refresh/', refresh_jwt_token), 103 | > 104 | > path('user/', include('user.urls')), 105 | > path('todo/', include('todo.urls')) 106 | > 107 | > ] 108 | > ``` 109 | > 110 | > - `backend/user/urls.py` 111 | > ```python 112 | > from django.urls import path 113 | > from .views import current_user, UserList 114 | > 115 | > urlpatterns = [ 116 | > path('', UserList.as_view()), 117 | > path('current', current_user), 118 | > ] 119 | > ``` 120 | > 121 | > - `backend/todo/urls.py` 122 | > ```python 123 | > from django.urls import path 124 | > from . import views 125 | > 126 | > urlpatterns = [ 127 | > path('', views.ListTodo.as_view()), 128 | > path('/', views.DetailTodo.as_view()), 129 | > ] 130 | > ``` 131 | 132 | ### Frontend Components 133 | 134 | > **Component description** 135 | > 136 | > - 로그인/회원가입 관련 Components 137 | > 138 | > | File Name | Directory | 목적 | 139 | > | --- | --- | --- | 140 | > | LoginForm.js | /components/auth/ |로그인 Form (기존 회원 or 구글 계정)| 141 | > | SignupForm.js | /components/auth/ |회원가입 Form| 142 | > | GoogleLoginButton.js | /components/auth/ |Custom Google Login Button 컴포넌트| 143 | > 144 | > - 라우팅 관련 Components 145 | > 146 | > | File Name | Directory | 목적 | 147 | > | --- | --- | --- | 148 | > | AppliedRoute.js | /components/routing/ |토큰 인증 필요 없는 컴포넌트 라우팅| 149 | > | AthenticatedRoute.js | /components/routing/ |토큰 인증 필요로 하는 컴포넌트 라우팅| 150 | > | Nav.js | /components/routing/ |메뉴 네비게이션 라우팅| 151 | > 152 | > - Todo 관련 Components 153 | > 154 | > | File Name | Directory | 목적 | 155 | > | --- | --- | --- | 156 | > | TodoList.js | /components/todo/ |서버로 인증 후 받아온 Todo의 CRUD를 수행| 157 | > | TodoModal.js | /components/todo/ |Todo Create/Update 하기 위한 모달 윈도 컴포넌트| 158 | > 159 | > - State 및 핸들러 관련 Components 160 | > 161 | > | File Name | Directory | 목적 | 162 | > | --- | --- | --- | 163 | > | Login.js | /containers/ |LoginForm에 대한 로직 수행하는 컴포넌트| 164 | > | Signup.js | /containers/ |SignupForm에 대한 로직 수행하는 컴포넌트| 165 | > | Home.js | /containers/ |토큰이 유효성에 따라 결과를 렌더링 시켜주는 컴포넌트| 166 | > 167 | 168 | ### Authentication and Authorization 169 | 170 | > **JWT configuration** 171 | > 172 | > - JWT 발급 및 검증 과정과 갱신 관련한 기능은 Django-REST-Framework-JWT를 사용하여 구현 173 | > - `backend/api/settings.py` 174 | > 175 | > ```python 176 | > REST_FRAMEWORK = { 177 | > 'DEFAULT_PERMISSION_CLASSES': ( 178 | > 'rest_framework.permissions.IsAuthenticated', 179 | > ), 180 | > 'DEFAULT_AUTHENTICATION_CLASSES': ( 181 | > 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', 182 | > ), 183 | > } 184 | > ``` 185 | > - Django 서버에서로그인 여부를 확인하는 클래스(DEFAULT_PERMISSION_CLASSES)를 rest_framework.permissions.IsAuthenticated로 사용하도록 설정 186 | > - 로그인과 관련된 클래스(DEFAULT_AUTHENTICATION_CLASSES)를 JWT(rest_framework_jwt.authentication.JSONWebTokenAuthentication)을 사용하도록 설정 187 | > - `backend/api/settings.py` 188 | > 189 | > ```python 190 | > JWT_AUTH = { 191 | > 'JWT_SECRET_KEY': SECRET_KEY, 192 | > 'JWT_ALGORITHM': 'HS256', 193 | > 'JWT_VERIFY_EXPIRATION' : True, 194 | > 'JWT_ALLOW_REFRESH': True, 195 | > 'JWT_EXPIRATION_DELTA': datetime.timedelta(minutes=10), 196 | > 'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(hours=1), 197 | > 'JWT_RESPONSE_PAYLOAD_HANDLER': 'api.custom_responses.my_jwt_response_handler' 198 | > } 199 | > ``` 200 | > - `JWT_SECRET_KEY` : JWT_SECRET_KEY는 Django 프로젝트의 SECRET_KEY로 사용 201 | > - `JWT_ALGORITHM` : 암호화 함수는 HS256으로 설정 202 | > - `JWT_VERIFY_EXPIRATION` : JWT 검증시, 만료 기간 확인 203 | > - `JWT_ALLOW_REFRESH` : JWT 갱신 허용 204 | > - `JWT_EXPIRATION_DELTA` : Access Token의 만료 시간은 10분으로 설정. 10분 지나면 토큰 만료되고, 리소스 접근시 자동 로그아웃 205 | > - `JWT_REFRESH_EXPIRATION_DELTA` : Refresh Token의 만료 시간은 1시간으로 설정. Access Token이 만료되기 전에 계속해서 갱신 가능하지만 1시간 지나면 더이상 갱신 불가 206 | > - `JWT_RESPONSE_PAYLOAD_HANDLER` : JWT Paylod 핸들링 할때 미들웨어 커스텀 핸들러 my_jwt_response_handler 거치도록 설정 207 | 208 | 209 | ### DataBase Models 210 | > 211 | > **Database schema** 212 | > 213 | > database-schema 214 | 215 | ### System configuration 216 | > 217 | > **Service flow** 218 | > 219 | > system-configuration 220 | > 221 | > 1. 사용자는 Google API Server에 로그인을 요청 222 | > 223 | > 2. Google API Server는 사용자에게 특정 쿼리들을 붙인 Google 로그인 Dialog를 트리거 224 | > 225 | > 3. Google 로그인 인증이 완료되면, access_token, id_token과 함께 권한 증서를 전달 226 | > 227 | > 4. id_token과 함께 권한 증서를 Authorization Server에 전달 228 | > 229 | > 5. Authorization Server는 Client Web App으로 부터 받은 토큰과 권한 증서의 유효성을 검증 230 | > 231 | > 6. 검증이 완료되면, 요청한 access_token이 유효하다고 간주 232 | > 233 | > 7. 서버는 요청한 리소스와 함께 access_token을 다시 전송 (로그인 인증 과정 완료) 234 | > 235 | > 8. 로그인 인증이 완료되면 Protected 리소스에 접근 가능하고, Refresh Token 주기적으로 발급하며 Token을 갱신 236 | > 237 | 238 |
239 | 240 | 241 | ## *Installation* 242 | 243 | ### Clone project 244 | > 245 | > - Github repository를 clone 246 | > ```bash 247 | > $ git clone https://github.com/meh9184/django-react-todo-manager.git 248 | > ``` 249 | > 250 | 251 | ### Configure google api configure 252 | > 253 | > - `frontend/public/index.html` 파일의 254 | > - header meta 태그에 google-signin-client_id 설정 255 | > - 개인 구글 계정에서 발급받은 Client ID로 입력 256 | > 257 | > ```html 258 | > 259 | > 260 | > 261 | > 262 | > 263 | > 264 | > 265 | > 266 | > 267 | > 268 | > 269 | > 270 | > 271 | > 272 | > 273 | > Todo App 274 | > 275 | > 16 | 28 | 29 | 30 | 33 |
34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App .navbar-brand { 2 | font-weight: bold; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import NavBar from "./components/routing/Nav"; 3 | import Routes from "./Routes"; 4 | import "./App.css"; 5 | 6 | class App extends Component { 7 | constructor(props) { 8 | super(props); 9 | 10 | this.state = { 11 | username: null, 12 | isAuthenticated: localStorage.getItem('token') ? true : false 13 | }; 14 | } 15 | 16 | // user가 로그인 중인지 확인하고, 로그인 중이라면 유저의 정보를 서버로부터 받아온다. 17 | componentDidMount() { 18 | 19 | // 토큰(access token)이 이미 존재하는 상황이라면 서버에 GET /validate 요청하여 해당 access token이 유효한지 확인 20 | if (this.state.isAuthenticated) { 21 | 22 | let handleErrors = response => { 23 | if (!response.ok) { 24 | throw Error(response.statusText); 25 | } 26 | return response; 27 | } 28 | 29 | 30 | // 현재 JWT 토큰 값이 타당한지 GET /validate 요청을 통해 확인하고 31 | // 상태 코드가 200이라면 현재 GET /user/current 요청을 통해 user정보를 받아옴 32 | fetch('http://localhost:8000/validate/', { 33 | headers: { 34 | Authorization: `JWT ${localStorage.getItem('token')}` 35 | } 36 | }) 37 | .then(res => { 38 | 39 | fetch('http://localhost:8000/user/current', { 40 | headers: { 41 | Authorization: `JWT ${localStorage.getItem('token')}` 42 | } 43 | }) 44 | .then(handleErrors) 45 | .then(res => res.json()) 46 | .then(json => { 47 | // 현재 유저 정보 받아왔다면, 로그인 상태로 state 업데이트 하고 48 | if (json.username) { 49 | this.setState({ username: json.username }); 50 | } 51 | 52 | // Refresh Token 발급 받아 token의 만료 시간 연장 53 | fetch('http://localhost:8000/refresh/', { 54 | method: 'POST', 55 | headers: { 56 | 'Content-Type': 'application/json' 57 | }, 58 | body: JSON.stringify({ 59 | token: localStorage.getItem('token') 60 | }) 61 | }) 62 | .then(handleErrors) 63 | .then(res => res.json()) 64 | .then((json)=>{ 65 | 66 | this.userHasAuthenticated(true, json.user.username, json.token); 67 | 68 | console.log('Refresh Token 발급'); 69 | console.log(json.token); 70 | }) 71 | .catch(error => { 72 | 73 | console.log(error); 74 | 75 | }); 76 | ; 77 | 78 | 79 | }) 80 | .catch(error => { 81 | 82 | this.handleLogout(); 83 | 84 | }); 85 | }) 86 | .catch(error => { 87 | 88 | this.handleLogout(); 89 | 90 | }); 91 | 92 | 93 | } 94 | } 95 | 96 | // 새로운 User가 로그인 했다면 (서버로 부터 access token을 발급받았을 것이고) 해당 토큰을 localStorage에 저장 97 | userHasAuthenticated = (authenticated, username, token) => { 98 | this.setState({ 99 | isAuthenticated: authenticated, 100 | username: username 101 | }); 102 | localStorage.setItem('token', token); 103 | } 104 | 105 | // 로그인 상태였던 유저가 로그아웃을 시도한다면 토큰을 지움 106 | handleLogout = () => { 107 | 108 | // 이 부분 이슈 잡아야 하는데, 사실 f5 리프레쉬 됐을때 구글 로그인 로직이 자동 호출되는 것만 막으면 됨 109 | // Login.js -> handleGoogleSignIn() 함수 110 | try { 111 | 112 | window.gapi && window.gapi.auth2.getAuthInstance().signOut() 113 | .then( () => { 114 | 115 | this.setState({ 116 | isAuthenticated: false, 117 | username: '' 118 | }); 119 | localStorage.removeItem('token'); 120 | console.log('Logged out successfully'); 121 | 122 | }); 123 | }catch{ 124 | 125 | this.setState({ 126 | isAuthenticated: false, 127 | username: '' 128 | }); 129 | localStorage.removeItem('token'); 130 | console.log('Logged out successfully'); 131 | 132 | } 133 | 134 | 135 | 136 | 137 | 138 | } 139 | 140 | render() { 141 | const childProps = { 142 | username: this.state.username, 143 | isAuthenticated: this.state.isAuthenticated, 144 | userHasAuthenticated: this.userHasAuthenticated 145 | }; 146 | return ( 147 |
148 | 153 | 154 |
155 | ); 156 | } 157 | } 158 | 159 | export default App; 160 | -------------------------------------------------------------------------------- /frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/Routes.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Switch } from "react-router-dom"; 3 | import AppliedRoute from "./components/routing/AppliedRoute"; 4 | import AuthenticatedRoute from "./components/routing/AuthenticatedRoute"; 5 | import NotFound from "./components/routing/NotFound"; 6 | import Home from "./containers/Home"; 7 | import Login from "./containers/Login"; 8 | import Signup from "./containers/Signup"; 9 | 10 | export default ({ childProps }) => 11 | 12 | 13 | 14 | 15 | 16 | ; 17 | -------------------------------------------------------------------------------- /frontend/src/components/auth/GoogleLoginButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class GoogleLoginButton extends React.Component { 4 | constructor(props) { 5 | super(props); 6 | this.state = { 7 | isLoggedIn: false 8 | }; 9 | } 10 | 11 | toggleLoggedIn = () => this.setState(state => { 12 | return { isLoggedIn: !state.isLoggedIn }; 13 | }); 14 | 15 | onSignIn = googleUser => { 16 | 17 | this.props.onSignIn(googleUser); 18 | 19 | }; 20 | 21 | renderGoogleLoginButton = () => { 22 | // console.log('rendering google signin button'); 23 | 24 | window.gapi.signin2.render('my-signin2', { 25 | scope: 'profile email', 26 | width: 250, 27 | height: 40, 28 | longtitle: true, 29 | theme: 'light', 30 | onsuccess: this.onSignIn, 31 | // onsuccess: this.props.onSignIn, 32 | }); 33 | }; 34 | 35 | logout = () => { 36 | console.log('logout'); 37 | 38 | let auth2 = window.gapi && window.gapi.auth2.getAuthInstance(); 39 | if (auth2) { 40 | auth2 41 | .signOut() 42 | .then(() => { 43 | this.toggleLoggedIn(); 44 | console.log('Logged out successfully'); 45 | }) 46 | .catch(err => { 47 | console.log('Error while logging out', err); 48 | }); 49 | } else { 50 | console.log('error while logging out'); 51 | } 52 | }; 53 | 54 | componentDidMount() { 55 | window.addEventListener('google-loaded', this.renderGoogleLoginButton); 56 | window.gapi && this.renderGoogleLoginButton(); 57 | } 58 | 59 | render() { 60 | return ( 61 |
62 |
63 | {this.state.isLoggedIn && ( 64 | 67 | )} 68 |
69 | ); 70 | } 71 | } 72 | 73 | export default GoogleLoginButton; 74 | -------------------------------------------------------------------------------- /frontend/src/components/auth/LoginForm.css: -------------------------------------------------------------------------------- 1 | @media all and (min-width: 480px) { 2 | .Login { 3 | padding: 60px 0; 4 | } 5 | 6 | .Login-google { 7 | padding-top: 15px; 8 | justify-content: center; 9 | display: flex; 10 | } 11 | 12 | .Login-google button { 13 | width: 100%; 14 | justify-content: center; 15 | } 16 | 17 | .Login form { 18 | margin: 0 auto; 19 | max-width: 320px; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/auth/LoginForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, FormGroup, FormControl, ControlLabel } from "react-bootstrap"; 3 | import "./LoginForm.css" 4 | import GoogleLoginButton from "./GoogleLoginButton"; 5 | 6 | 7 | // presentational component, only a stateless function 8 | // gets props by destructuring the props object 9 | // note that the input fields use the props to render their value attribute 10 | const LoginForm = ({ 11 | username, password, 12 | handleChangeUsername, 13 | handleChangePassword, 14 | handleSubmit, 15 | handleGoogleSignIn, 16 | validate, 17 | isAuthenticated 18 | }) => { 19 | return ( 20 |
21 |
22 | 23 | Username 24 | 29 | 30 | 31 | Password 32 | 37 | 38 | 46 | 47 |
48 | 52 |
53 | 54 | 55 |
56 | 57 |
58 | ); 59 | } 60 | 61 | export default LoginForm; 62 | -------------------------------------------------------------------------------- /frontend/src/components/auth/SignupForm.css: -------------------------------------------------------------------------------- 1 | @media all and (min-width: 480px) { 2 | .Signup { 3 | padding: 60px 0; 4 | } 5 | 6 | .Signup form { 7 | margin: 0 auto; 8 | max-width: 320px; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/components/auth/SignupForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, FormGroup, FormControl, ControlLabel } from "react-bootstrap"; 3 | import "./SignupForm.css" 4 | 5 | // 구글로 로그인하지 않고 서버에 직접 회원 등록하는 경우 폼 6 | const SignupForm = ({username, password, handleChangeUsername, handleChangePassword, handleSubmit, validate}) => { 7 | return ( 8 |
9 |
10 | 11 | Username 12 | 17 | 18 | 19 | Password 20 | 25 | 26 | 34 |
35 |
36 | ); 37 | } 38 | 39 | export default SignupForm; 40 | -------------------------------------------------------------------------------- /frontend/src/components/routing/AppliedRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route } from "react-router-dom"; 3 | 4 | // 인증 필요로하지 않는 컴포넌트 라우팅 5 | export default ({ component: C, props: cProps, ...rest }) => 6 | 9 | } 10 | />; 11 | -------------------------------------------------------------------------------- /frontend/src/components/routing/AuthenticatedRoute.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Route, Redirect } from "react-router-dom"; 3 | 4 | // 인증 필요로하는 컴포넌트 라우팅 5 | export default ({ component: C, props: cProps, ...rest }) => 6 | 9 | cProps.isAuthenticated 10 | ? 11 | : } 14 | />; 15 | -------------------------------------------------------------------------------- /frontend/src/components/routing/Nav.js: -------------------------------------------------------------------------------- 1 | import React, {Fragment} from 'react'; 2 | import {Navbar, Nav, NavItem } from 'react-bootstrap'; 3 | import { Link } from "react-router-dom"; 4 | import { LinkContainer } from "react-router-bootstrap"; 5 | 6 | // 메뉴 컴포넌트 7 | const NavBar = ({isAuthenticated, username, handleLogout}) => { 8 | return ( 9 | 10 | 11 | 12 | {isAuthenticated 13 | ? {username} 14 | : 'JWT-Todo-Demo Client' 15 | } 16 | 17 | 18 | 19 | 20 | 33 | 34 | 35 | ); 36 | } 37 | 38 | export default NavBar; 39 | -------------------------------------------------------------------------------- /frontend/src/components/routing/NotFound.css: -------------------------------------------------------------------------------- 1 | .NotFound { 2 | padding-top: 100px; 3 | text-align: center; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/components/routing/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./NotFound.css"; 3 | 4 | // 404 페이지 5 | export default () => 6 |
7 |

Sorry, page not found!

8 |
; 9 | -------------------------------------------------------------------------------- /frontend/src/components/todos/TodoList.css: -------------------------------------------------------------------------------- 1 | .modal-dialog { 2 | position: relative; 3 | top: 200px; 4 | } 5 | 6 | .todo-title { 7 | cursor: pointer; 8 | } 9 | 10 | .completed-todo { 11 | text-decoration: line-through; 12 | } 13 | 14 | .tab-list > span { 15 | padding: 5px 8px; 16 | border: 1px solid #282c34; 17 | border-radius: 10px; 18 | margin-right: 5px; 19 | cursor: pointer; 20 | } 21 | 22 | .tab-list > span.active { 23 | background-color: #282c34; 24 | color: #ffffff; 25 | } 26 | 27 | .show { 28 | opacity: 1; 29 | } 30 | 31 | .row { 32 | width: 100%; 33 | justify-content: center; 34 | display: flex; 35 | } 36 | 37 | .card { 38 | border: 1px solid #dcdcdc; 39 | border-radius: 30px; 40 | padding: 20px; 41 | } 42 | 43 | .card div { 44 | margin-bottom: 30px; 45 | } 46 | 47 | li.list-group-item { 48 | /* margin-bottom: 10px; */ 49 | border-left-color: white; 50 | border-right-color: white; 51 | padding: 20px 20px 25px 0px; 52 | } 53 | 54 | span.todo-action{ 55 | position:relative; 56 | float: right; 57 | } 58 | 59 | span.todo-action button{ 60 | margin-left: 10px; 61 | padding-bottom: 3px; 62 | } 63 | 64 | span.todo-description.mr-2{ 65 | color: grey; 66 | display: inline-block; 67 | } -------------------------------------------------------------------------------- /frontend/src/components/todos/TodoList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import axios from "axios"; 3 | // import 'bootstrap/dist/css/bootstrap.min.css'; 4 | import Modal from "./TodoModal"; 5 | import "./TodoList.css" 6 | 7 | class TodoList extends Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | viewCompleted: false, 12 | activeItem: { 13 | title: "", 14 | description: "", 15 | completed: false 16 | }, 17 | todoList: [] 18 | }; 19 | } 20 | componentDidMount() { 21 | this.refreshList(); 22 | } 23 | refreshList = () => { 24 | axios 25 | .get("http://localhost:8000/todo/", { 26 | headers: { 27 | Authorization: `JWT ${localStorage.getItem('token')}` 28 | }, 29 | }) 30 | .then(res => this.setState({ todoList: res.data })) 31 | .catch(err => console.log(err)); 32 | }; 33 | displayCompleted = status => { 34 | if (status) { 35 | return this.setState({ viewCompleted: true }); 36 | } 37 | return this.setState({ viewCompleted: false }); 38 | }; 39 | renderTabList = () => { 40 | return ( 41 |
42 | this.displayCompleted(true)} 44 | className={this.state.viewCompleted ? "active" : ""} 45 | > 46 | complete 47 | 48 | this.displayCompleted(false)} 50 | className={this.state.viewCompleted ? "" : "active"} 51 | > 52 | Incomplete 53 | 54 |
55 | ); 56 | }; 57 | renderItems = () => { 58 | const { viewCompleted } = this.state; 59 | const newItems = this.state.todoList.filter( 60 | item => item.completed === viewCompleted 61 | ); 62 | return newItems.map(item => ( 63 |
  • 67 |
      70 | 76 |

      {item.title}

      77 |
      78 |
    79 | 80 |
      83 | 89 | {item.description} 90 | 91 | 92 | 95 | 102 | 108 | 109 | 110 | 111 | 112 | 113 |
    114 | 115 |
  • 116 | )); 117 | }; 118 | toggle = () => { 119 | 120 | this.setState({ modal: !this.state.modal }); 121 | 122 | }; 123 | handleSubmit = item => { 124 | this.toggle(); 125 | if (item.id) { 126 | axios 127 | .put(`http://localhost:8000/todo/${item.id}/`, item, { 128 | headers: { 129 | Authorization: `JWT ${localStorage.getItem('token')}` 130 | }, 131 | }) 132 | .then(res => this.refreshList()); 133 | return; 134 | } 135 | axios 136 | .post("http://localhost:8000/todo/", item, { 137 | headers: { 138 | Authorization: `JWT ${localStorage.getItem('token')}` 139 | }, 140 | }) 141 | .then(res => this.refreshList()); 142 | }; 143 | handleDelete = item => { 144 | axios 145 | .delete(`http://localhost:8000/todo/${item.id}`, { 146 | headers: { 147 | Authorization: `JWT ${localStorage.getItem('token')}` 148 | }, 149 | }) 150 | .then(res => this.refreshList()); 151 | }; 152 | createItem = () => { 153 | const item = { title: "", description: "", completed: false }; 154 | this.setState({ activeItem: item, modal: !this.state.modal }); 155 | }; 156 | editItem = item => { 157 | this.setState({ activeItem: item, modal: !this.state.modal }); 158 | }; 159 | render() { 160 | return ( 161 |
    162 |

    Todo List

    163 |
    164 |
    165 |
    166 |
    167 | 170 |
    171 | {this.renderTabList()} 172 |
      173 | {this.renderItems()} 174 |
    175 |
    176 |
    177 |
    178 | {this.state.modal ? ( 179 | 184 | ) : null} 185 |
    186 | ); 187 | } 188 | } 189 | export default TodoList; 190 | -------------------------------------------------------------------------------- /frontend/src/components/todos/TodoModal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { 3 | Button, 4 | Modal, 5 | ModalHeader, 6 | ModalBody, 7 | ModalFooter, 8 | Form, 9 | FormGroup, 10 | Input, 11 | Label 12 | } from "reactstrap"; 13 | 14 | export default class CustomModal extends Component { 15 | constructor(props) { 16 | super(props); 17 | this.state = { 18 | activeItem: this.props.activeItem 19 | }; 20 | } 21 | 22 | handleChange = e => { 23 | let { name, value } = e.target; 24 | if (e.target.type === "checkbox") { 25 | value = e.target.checked; 26 | } 27 | const activeItem = { ...this.state.activeItem, [name]: value }; 28 | this.setState({ activeItem }); 29 | }; 30 | 31 | render() { 32 | const { toggle, onSave } = this.props; 33 | return ( 34 | 35 | Todo Item 36 | 37 |
    38 | 39 | 40 | 47 | 48 | 49 | 50 | 57 | 58 | 59 | 68 | 69 |
    70 |
    71 | 72 | 75 | 76 |
    77 | ); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/src/containers/Home.css: -------------------------------------------------------------------------------- 1 | .Home h3 { 2 | padding: 60px 0 0 0; 3 | text-align: center; 4 | } 5 | 6 | .Home .lander { 7 | padding: 30px; 8 | } 9 | 10 | .Home .lander h1 { 11 | font-family: "Open Sans", sans-serif; 12 | font-weight: 600; 13 | padding: 10px 0 30px 0; 14 | } 15 | 16 | .Home .lander p { 17 | color: #999; 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/containers/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import TodoList from "../components/todos/TodoList"; 3 | import "./Home.css"; 4 | 5 | 6 | export default class Home extends Component { 7 | state = { 8 | todos: [] 9 | }; 10 | 11 | componentDidMount() { 12 | let handleErrors = response => { 13 | if (!response.ok) { 14 | throw Error(response.statusText); 15 | } 16 | return response; 17 | } 18 | 19 | // todo api를 요청하기 위해 현재 access 토큰을 보내 타당한지 확인하고, 타탕하다면 해당 리소스 접근 20 | // 타당성 확인은 서버측에서 담당 21 | fetch('http://localhost:8000/todo/', { 22 | headers: { 23 | Authorization: `JWT ${localStorage.getItem('token')}` 24 | } 25 | }) 26 | .then(handleErrors) 27 | .then(res => res.json()) 28 | .then(json => { 29 | this.setState({ 30 | todos: json 31 | }); 32 | }) 33 | .catch(error => alert(error)); 34 | } 35 | 36 | render() { 37 | return ( 38 |
    39 |

    Hi, {this.props.username}

    40 |
    41 |
    42 | 45 |
    46 |
    47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/containers/Login.js: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from "react"; 2 | import LoginForm from "../components/auth/LoginForm"; 3 | 4 | // 로그인 로직을 수행하는 컴포넌트 5 | export default class Login extends Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | username: "", 11 | password: "" 12 | }; 13 | } 14 | 15 | // 만약 유저가 이미 로그인된 상태라면 home으로 이동 16 | componentDidMount() { 17 | if (this.props.isAuthenticated) { 18 | this.props.history.push("/"); 19 | } 20 | } 21 | 22 | validateForm(username, password) { 23 | return (username && username.length > 0) && (password && password.length > 0); 24 | } 25 | 26 | handleChange = event => { 27 | this.setState({ 28 | [event.target.id]: event.target.value 29 | }); 30 | } 31 | 32 | // 구글 로그인 버튼을 클릭한 경우 로직 33 | // 처음 로그인을 시도하는 경우라면 구글 리소스 서버로부터 받은 정보를 이용하여 서버에 User 생성하고 로그인 34 | // 이미 로그인을 시도한 적 있는 경우라면 서버로부터 해당 정보를 받아와 로그인 35 | handleGoogleSignIn(googleUser) { 36 | 37 | let profile = googleUser.getBasicProfile(); 38 | 39 | let username = profile.getName(); 40 | let email = profile.getEmail(); 41 | let id_token = profile.getId(); 42 | let firstname = profile.getGivenName() 43 | let lastname = profile.getFamilyName() 44 | 45 | // let access_token = googleUser.getAuthResponse().id_token; 46 | 47 | // console.log('username: ' + username); 48 | // console.log('email: ' + email); 49 | // console.log('id_token: ' + id_token); 50 | // console.log('access_token: ' + access_token); 51 | // console.log('Given Name: ' + profile.getGivenName()); 52 | // console.log('Family Name: ' + profile.getFamilyName()); 53 | 54 | let data = { 55 | username: username, 56 | first_name: firstname, 57 | last_name: lastname, 58 | email: email, 59 | password: id_token, 60 | provider: 'google' 61 | }; 62 | 63 | // 서버에 Google 계정 저장돼 있지 않다면 Create 작업 수행 64 | fetch('http://localhost:8000/user/', { 65 | method: 'POST', 66 | headers: { 67 | 'Content-Type': 'application/json' 68 | }, 69 | body: JSON.stringify(data) 70 | }) 71 | .then(res => res.json()) 72 | .then(json => { 73 | 74 | if (json.username && json.token) { 75 | 76 | // console.log('token: ' + json.token); 77 | 78 | this.props.userHasAuthenticated(true, json.username, json.token); 79 | this.props.history.push("/"); 80 | 81 | }else{ 82 | 83 | // 서버에 Google 계정 이미 저장돼 있다면 Login 작업 수행 84 | // 로그인을 시도하기 전에 서버에 접근하기 위한 access token을 발급 받음 85 | fetch('http://localhost:8000/login/', { 86 | method: 'POST', 87 | headers: { 88 | 'Content-Type': 'application/json' 89 | }, 90 | body: JSON.stringify(data) 91 | }) 92 | .then(res => res.json()) 93 | .then(json => { 94 | 95 | // 발급 완료 되었다면 해당 토큰을 클라이언트 Local Storage에 저장 96 | if (json.user && json.user.username && json.token) { 97 | 98 | // console.log('token: ' + json.token); 99 | 100 | this.props.userHasAuthenticated(true, json.user.username, json.token); 101 | this.props.history.push("/"); 102 | 103 | } 104 | }) 105 | .catch(error => { 106 | console.log(error); 107 | window.gapi && window.gapi.auth2.getAuthInstance().signOut(); 108 | }); 109 | 110 | } 111 | 112 | }) 113 | .catch(error => { 114 | console.log(error); 115 | window.gapi && window.gapi.auth2.getAuthInstance().signOut(); 116 | }); 117 | 118 | 119 | } 120 | 121 | // 서버에 등록되어있는 회원 정보로 로그인을 시도하는 경우 122 | handleSubmit(submitEvent) { 123 | 124 | let data = { 125 | username: this.state.username, 126 | password: this.state.password 127 | }; 128 | 129 | submitEvent.preventDefault(); 130 | 131 | let handleErrors = response => { 132 | if (!response.ok) { 133 | throw Error(response.statusText); 134 | } 135 | return response; 136 | } 137 | 138 | // 서버로부터 새로운 access token 발급받음 139 | fetch('http://localhost:8000/login/', { 140 | method: 'POST', 141 | headers: { 142 | 'Content-Type': 'application/json' 143 | }, 144 | body: JSON.stringify(data) 145 | }) 146 | .then(handleErrors) 147 | .then(res => res.json()) 148 | .then(json => { 149 | // 발급 완료 되었다면 해당 토큰을 클라이언트 Local Storage에 저장 150 | if (json.user && json.user.username && json.token) { 151 | this.props.userHasAuthenticated(true, json.user.username, json.token); 152 | this.props.history.push("/"); 153 | } 154 | }) 155 | .catch(error => alert(error)); 156 | } 157 | 158 | render() { 159 | return ( 160 | 161 | this.handleChange(e)} 165 | handleChangePassword={e => this.handleChange(e)} 166 | handleSubmit={e => this.handleSubmit(e)} 167 | handleGoogleSignIn={e => this.handleGoogleSignIn(e)} 168 | validate={this.validateForm} 169 | /> 170 | 171 | 172 | 173 | ); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /frontend/src/containers/Signup.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import SignupForm from "../components/auth/SignupForm"; 3 | 4 | // 구글 계정으로 로그인하지 않는 경우 회원 생성 로직을 수행하는 컴포넌트 5 | export default class Signup extends Component { 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | username: "", 11 | password: "" 12 | }; 13 | } 14 | 15 | componentDidMount() { 16 | if (this.props.isAuthenticated) { 17 | this.props.history.push("/"); 18 | } 19 | } 20 | 21 | validateForm(username, password) { 22 | return (username && username.length > 0) && (password && password.length > 0); 23 | } 24 | 25 | handleChange = event => { 26 | this.setState({ 27 | [event.target.id]: event.target.value 28 | }); 29 | } 30 | 31 | async handleSubmit(submitEvent) { 32 | let data = { 33 | username: this.state.username, 34 | password: this.state.password 35 | }; 36 | 37 | submitEvent.preventDefault(); 38 | 39 | let handleErrors = response => { 40 | if (!response.ok) { 41 | throw Error(response.statusText); 42 | } 43 | return response; 44 | } 45 | 46 | fetch('http://localhost:8000/user/', { 47 | method: 'POST', 48 | headers: { 49 | 'Content-Type': 'application/json' 50 | }, 51 | body: JSON.stringify(data) 52 | }) 53 | .then(handleErrors) 54 | .then(res => res.json()) 55 | .then(json => { 56 | if (json.username && json.token) { 57 | this.props.userHasAuthenticated(true, json.username, json.token); 58 | this.props.history.push("/"); 59 | } 60 | }) 61 | .catch(error => alert(error)); 62 | } 63 | 64 | render() { 65 | return ( 66 | this.handleChange(e)} 70 | handleChangePassword={e => this.handleChange(e)} 71 | handleSubmit={e => this.handleSubmit(e)} 72 | validate={this.validateForm} 73 | /> 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 13 | monospace; 14 | } -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { BrowserRouter as Router } from "react-router-dom"; 4 | import App from "./App"; 5 | import "./index.css"; 6 | import * as serviceWorker from './serviceWorker'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById("root") 13 | ); 14 | 15 | serviceWorker.unregister(); 16 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | const isLocalhost = Boolean( 2 | window.location.hostname === 'localhost' || 3 | window.location.hostname === '[::1]' || 4 | window.location.hostname.match( 5 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 6 | ) 7 | ); 8 | 9 | export function register(config) { 10 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 11 | 12 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 13 | if (publicUrl.origin !== window.location.origin) { 14 | 15 | return; 16 | } 17 | 18 | window.addEventListener('load', () => { 19 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 20 | 21 | if (isLocalhost) { 22 | 23 | checkValidServiceWorker(swUrl, config); 24 | 25 | navigator.serviceWorker.ready.then(() => { 26 | console.log( 27 | 'This web app is being served cache-first by a service ' + 28 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 29 | ); 30 | }); 31 | } else { 32 | 33 | registerValidSW(swUrl, config); 34 | } 35 | }); 36 | } 37 | } 38 | 39 | function registerValidSW(swUrl, config) { 40 | navigator.serviceWorker 41 | .register(swUrl) 42 | .then(registration => { 43 | registration.onupdatefound = () => { 44 | const installingWorker = registration.installing; 45 | installingWorker.onstatechange = () => { 46 | if (installingWorker.state === 'installed') { 47 | if (navigator.serviceWorker.controller) { 48 | console.log('New content is available; please refresh.'); 49 | 50 | // Execute callback 51 | if (config.onUpdate) { 52 | config.onUpdate(registration); 53 | } 54 | } else { 55 | console.log('Content is cached for offline use.'); 56 | 57 | if (config.onSuccess) { 58 | config.onSuccess(registration); 59 | } 60 | } 61 | } 62 | }; 63 | }; 64 | }) 65 | .catch(error => { 66 | console.error('Error during service worker registration:', error); 67 | }); 68 | } 69 | 70 | function checkValidServiceWorker(swUrl, config) { 71 | 72 | fetch(swUrl) 73 | .then(response => { 74 | 75 | if ( 76 | response.status === 404 || 77 | response.headers.get('content-type').indexOf('javascript') === -1 78 | ) { 79 | 80 | navigator.serviceWorker.ready.then(registration => { 81 | registration.unregister().then(() => { 82 | window.location.reload(); 83 | }); 84 | }); 85 | } else { 86 | 87 | registerValidSW(swUrl, config); 88 | } 89 | }) 90 | .catch(() => { 91 | console.log( 92 | 'No internet connection found. App is running in offline mode.' 93 | ); 94 | }); 95 | } 96 | 97 | export function unregister() { 98 | if ('serviceWorker' in navigator) { 99 | navigator.serviceWorker.ready.then(registration => { 100 | registration.unregister(); 101 | }); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /screenshots/database_schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meh9184/django-react-todo-manager/daffd1b55ddb113a06fc9f69084e14e47d279d2a/screenshots/database_schema.png -------------------------------------------------------------------------------- /screenshots/main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meh9184/django-react-todo-manager/daffd1b55ddb113a06fc9f69084e14e47d279d2a/screenshots/main.jpg -------------------------------------------------------------------------------- /screenshots/system_flow.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meh9184/django-react-todo-manager/daffd1b55ddb113a06fc9f69084e14e47d279d2a/screenshots/system_flow.jpg -------------------------------------------------------------------------------- /screenshots/test.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meh9184/django-react-todo-manager/daffd1b55ddb113a06fc9f69084e14e47d279d2a/screenshots/test.gif -------------------------------------------------------------------------------- /screenshots/todolist.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meh9184/django-react-todo-manager/daffd1b55ddb113a06fc9f69084e14e47d279d2a/screenshots/todolist.jpg -------------------------------------------------------------------------------- /screenshots/todolist_complete.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meh9184/django-react-todo-manager/daffd1b55ddb113a06fc9f69084e14e47d279d2a/screenshots/todolist_complete.jpg -------------------------------------------------------------------------------- /screenshots/todolist_incomplete.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meh9184/django-react-todo-manager/daffd1b55ddb113a06fc9f69084e14e47d279d2a/screenshots/todolist_incomplete.jpg --------------------------------------------------------------------------------