├── .gitignore ├── .gitlab-ci.yml ├── .python-version ├── Readme.md ├── api ├── .dockerignore ├── .gitignore ├── Dockerfile ├── Dockerfile-prod ├── manage.py ├── migrations │ ├── README │ ├── alembic.ini │ ├── env.py │ ├── script.py.mako │ └── versions │ │ └── 3e36f657a477_initial_migration.py ├── project │ ├── __init__.py │ ├── config.py │ ├── db │ │ ├── Dockerfile │ │ └── create.sql │ ├── extensions │ │ ├── __init__.py │ │ └── logging.py │ └── modules │ │ ├── __init__.py │ │ ├── auth │ │ └── __init__.py │ │ ├── users │ │ ├── __init__.py │ │ └── models.py │ │ └── utils.py ├── requirements-dev.txt ├── requirements.txt └── setup.cfg ├── client ├── .dockerignore ├── .gitignore ├── Dockerfile ├── Dockerfile-prod ├── README.md ├── components │ ├── error-boundary.tsx │ ├── errors │ │ ├── five_hundred_error.tsx │ │ ├── four_zero_four_error.tsx │ │ └── index.ts │ └── layout │ │ ├── footer.tsx │ │ ├── header.tsx │ │ ├── index.ts │ │ ├── layout.tsx │ │ └── menu.tsx ├── next-env.d.ts ├── next.config.js ├── now.json ├── package-lock.json ├── package.json ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── _error.tsx │ ├── components.tsx │ ├── index.tsx │ ├── login.tsx │ └── profile.tsx ├── services │ ├── api │ │ ├── api-config.ts │ │ ├── api-problem.ts │ │ ├── api.ts │ │ └── index.ts │ ├── auth.ts │ ├── index.ts │ ├── types.ts │ ├── users.ts │ └── utils.ts ├── state │ ├── index.ts │ ├── reducers │ │ ├── auth.ts │ │ └── index.ts │ └── state-provider.tsx ├── static │ └── styles │ │ └── style.css ├── tsconfig.json └── utils │ ├── auth.tsx │ └── index.ts ├── deploy.sh ├── docker-compose.ci.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── nginx ├── Dockerfile ├── dev.conf ├── nginx.conf └── prod.conf └── setup_env.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | tags 3 | .tern_port 4 | tags.* 5 | node_modules 6 | desktop.ini 7 | .vscode 8 | .vim 9 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: 2 | name: docker/compose:1.24.1 3 | entrypoint: [""] 4 | 5 | services: 6 | - docker:dind 7 | 8 | stages: 9 | - build 10 | - deploy 11 | 12 | variables: 13 | DOCKER_HOST: tcp://docker:2375 14 | DOCKER_DRIVER: overlay2 15 | 16 | before_script: 17 | - export IMAGE=$CI_REGISTRY/$CI_PROJECT_NAMESPACE/$CI_PROJECT_NAME 18 | - apk add --no-cache openssh-client bash 19 | - docker login -u $CI_REGISTRY_USER -p $CI_JOB_TOKEN $CI_REGISTRY 20 | - chmod +x ./setup_env.sh 21 | - bash ./setup_env.sh 22 | 23 | build: 24 | stage: build 25 | script: 26 | - export API_IMAGE=$IMAGE:api 27 | - export CLIENT_IMAGE=$IMAGE:client 28 | - docker pull $IMAGE:api || true 29 | - docker pull $IMAGE:client || true 30 | - docker-compose -f docker-compose.ci.yml build --no-cache 31 | - docker push $IMAGE:api 32 | - docker push $IMAGE:client 33 | 34 | deploy: 35 | stage: deploy 36 | script: 37 | - export API_IMAGE=$IMAGE:api 38 | - export CLIENT_IMAGE=$IMAGE:client 39 | - mkdir -p ~/.ssh 40 | - echo "$PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa 41 | - cat ~/.ssh/id_rsa 42 | - chmod 700 ~/.ssh/id_rsa 43 | - eval "$(ssh-agent -s)" 44 | - ssh-add ~/.ssh/id_rsa 45 | - ssh-keyscan -H 'gitlab.com' >> ~/.ssh/known_hosts 46 | - chmod +x ./deploy.sh 47 | - scp -o StrictHostKeyChecking=no -r ./.env ./nginx/prod.conf ./nginx/nginx.conf ./docker-compose.prod.yml root@$DO_IP_ADDRESS:/app 48 | - bash ./deploy.sh 49 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | flask-react-docker-app 2 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Flask-React-Docker App 2 | 3 | An example of web application using Flask, React + NextJS with Docker. 4 | 5 | ## Getting Started 6 | 7 | This project consists of Flask application for the backend API, NextJS for client side application and nginx as a reverse-proxy for connecting api and the front-end. This project also use `docker-compose` to make it easy run all of the container at once. 8 | 9 | This application will showcase: 10 | 11 | - Flask application with Users and Auth endpoint 12 | - NextJS application that showing normal route and authenticated routes. 13 | 14 | ### Prerequisites 15 | 16 | Before you run this application make sure you have this installed in your machine: 17 | 18 | - [Docker Desktop](https://www.docker.com/products/docker-desktop) 19 | - [docker-compose](https://docs.docker.com/compose/install/) 20 | 21 | ### Running Locally 22 | 23 | To run the application locally, run this command 24 | 25 | ``` 26 | $ docker-compose up 27 | ``` 28 | 29 | #### Database Migration and Seed 30 | 31 | ``` 32 | # Run database migration 33 | $ docker-compose exec api python manage.py db upgrade 34 | 35 | # Run database seed 36 | $ docker-compose exec api python manage.py seed_db 37 | ``` 38 | 39 | The seeder will contain sample users data: 40 | 41 | ``` 42 | username : admin 43 | password : verysecurepassword 44 | ``` 45 | 46 | After you run above commands you can open the application from [http://localhost:8080/](http://localhost:8080/) 47 | -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | Dockerfile 3 | db.sqlite3 4 | __pycache__ 5 | *.pyc 6 | *.pyo 7 | *.pyd 8 | .Python 9 | env 10 | pip-log.txt 11 | pip-delete-this-directory.txt 12 | .tox 13 | .coverage 14 | .coverage.* 15 | .cache 16 | coverage.xml 17 | *,cover 18 | *.log 19 | .git 20 | htmlcov/ 21 | data/ 22 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | htmlcov 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | pip-wheel-metadata/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | .DS_Store 127 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | # Base Image 2 | FROM python:3.7.2-slim 3 | 4 | # Install netcat 5 | RUN apt-get update && \ 6 | apt-get -y install netcat && \ 7 | apt-get clean 8 | 9 | # set working directory 10 | WORKDIR /usr/src/app 11 | 12 | # add and install requirements 13 | COPY ./requirements.txt /usr/src/app/requirements.txt 14 | RUN pip install -r requirements.txt 15 | 16 | # add app 17 | COPY . /usr/src/app 18 | 19 | # run server 20 | CMD python manage.py run -h 0.0.0.0 21 | -------------------------------------------------------------------------------- /api/Dockerfile-prod: -------------------------------------------------------------------------------- 1 | # base image 2 | FROM python:3.7.2-slim 3 | 4 | # install netcat 5 | RUN apt-get update && \ 6 | apt-get -y install netcat && \ 7 | apt-get clean 8 | 9 | # set working directory 10 | WORKDIR /usr/src/app 11 | 12 | # add and install requirements 13 | COPY ./requirements.txt /usr/src/app/requirements.txt 14 | RUN pip install -r requirements.txt 15 | 16 | # add app 17 | COPY . /usr/src/app 18 | 19 | # run server 20 | CMD gunicorn -b 0.0.0.0:5000 manage:app 21 | 22 | -------------------------------------------------------------------------------- /api/manage.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import unittest 3 | import coverage 4 | from flask.cli import FlaskGroup 5 | from project import create_app 6 | from project.extensions import db 7 | from project.modules.users.models import Users 8 | 9 | COV = coverage.coverage(branch=True, 10 | include='project/*', 11 | omit=['project/tests/*', 'project/config.py']) 12 | COV.start() 13 | 14 | app = create_app() 15 | cli = FlaskGroup(create_app=create_app) 16 | 17 | 18 | @cli.command('recreate_db') 19 | def recreate_db(): 20 | db.drop_all() 21 | db.create_all() 22 | db.session.commit() 23 | 24 | 25 | @cli.command() 26 | def test(): 27 | """Run unit test without code coverage""" 28 | tests = unittest.TestLoader().discover('project/tests', pattern='test*.py') 29 | result = unittest.TextTestRunner(verbosity=2).run(tests) 30 | if result.wasSuccessful(): 31 | return 0 32 | 33 | sys.exit(result) 34 | 35 | 36 | @cli.command('seed_db') 37 | def seed_db(): 38 | """Seeds the database.""" 39 | db.session.add( 40 | Users(username='admin', 41 | email='admin@gmail.com', 42 | password='verysecurepassword')) 43 | db.session.commit() 44 | 45 | 46 | @cli.command() 47 | def cov(): 48 | """Runs the unit tests with coverage.""" 49 | tests = unittest.TestLoader().discover('project/tests', pattern='test*.py') 50 | result = unittest.TextTestRunner(verbosity=2).run(tests) 51 | if result.wasSuccessful(): 52 | COV.stop() 53 | COV.save() 54 | print('Coverage summary:') 55 | COV.report() 56 | COV.html_report() 57 | COV.erase() 58 | return 0 59 | sys.exit(result) 60 | 61 | 62 | if __name__ == '__main__': 63 | cli() 64 | -------------------------------------------------------------------------------- /api/migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /api/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /api/migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | config.set_main_option( 26 | 'sqlalchemy.url', current_app.config.get( 27 | 'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) 28 | target_metadata = current_app.extensions['migrate'].db.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, target_metadata=target_metadata, literal_binds=True 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | 65 | # this callback is used to prevent an auto-migration from being generated 66 | # when there are no changes to the schema 67 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 68 | def process_revision_directives(context, revision, directives): 69 | if getattr(config.cmd_opts, 'autogenerate', False): 70 | script = directives[0] 71 | if script.upgrade_ops.is_empty(): 72 | directives[:] = [] 73 | logger.info('No changes in schema detected.') 74 | 75 | connectable = engine_from_config( 76 | config.get_section(config.config_ini_section), 77 | prefix='sqlalchemy.', 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, 84 | target_metadata=target_metadata, 85 | process_revision_directives=process_revision_directives, 86 | **current_app.extensions['migrate'].configure_args 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /api/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /api/migrations/versions/3e36f657a477_initial_migration.py: -------------------------------------------------------------------------------- 1 | """initial_migration 2 | 3 | Revision ID: 3e36f657a477 4 | Revises: 5 | Create Date: 2019-09-19 07:13:00.447401 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '3e36f657a477' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('created', sa.DateTime(), nullable=False), 23 | sa.Column('updated', sa.DateTime(), nullable=False), 24 | sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), 25 | sa.Column('username', sa.String(length=128), nullable=False), 26 | sa.Column('password', sa.String(length=255), nullable=False), 27 | sa.Column('email', sa.String(length=128), nullable=True), 28 | sa.Column('admin', sa.Boolean(), nullable=False), 29 | sa.Column('active', sa.Boolean(), nullable=False), 30 | sa.PrimaryKeyConstraint('id'), 31 | sa.UniqueConstraint('email'), 32 | sa.UniqueConstraint('username') 33 | ) 34 | # ### end Alembic commands ### 35 | 36 | 37 | def downgrade(): 38 | # ### commands auto generated by Alembic - please adjust! ### 39 | op.drop_table('users') 40 | # ### end Alembic commands ### 41 | -------------------------------------------------------------------------------- /api/project/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Factory for main flask application 3 | """ 4 | import os 5 | 6 | from flask import Flask 7 | from flask_migrate import Migrate 8 | from werkzeug.middleware.proxy_fix import ProxyFix 9 | 10 | migrate = Migrate() 11 | 12 | 13 | def create_app(flask_config_name=None, **kwargs): 14 | """ 15 | Entry point to the Flask RESTful Server application. 16 | """ 17 | # This is a workaround for Alpine Linux (musl libc) quirk: 18 | # https://github.com/docker-library/python/issues/211 19 | import threading 20 | threading.stack_size(2 * 1024 * 1024) 21 | 22 | env_flask_config_name = os.getenv('APP_SETTINGS') 23 | 24 | app = Flask(__name__, **kwargs) 25 | app.wsgi_app = ProxyFix(app.wsgi_app) 26 | 27 | app.config.from_object(env_flask_config_name) 28 | 29 | from . import extensions 30 | extensions.init_app(app) 31 | 32 | migrate.init_app(app, extensions.db) 33 | 34 | from . import modules 35 | modules.initiate_app(app) 36 | 37 | @app.shell_context_processor 38 | def ctx(): 39 | return {'app': app, 'db': extensions.db} 40 | 41 | return app 42 | -------------------------------------------------------------------------------- /api/project/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class BaseConfig: 5 | PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) 6 | TESTING = False 7 | SECRET_KEY = os.environ.get('SECRET_KEY') 8 | SQLALCHEMY_TRACK_MODIFICATIONS = False 9 | BCRYPT_LOG_ROUNDS = 13 10 | TOKEN_EXPIRATION_DAYS = 30 11 | TOKEN_EXPIRATION_SECONDS = 0 12 | 13 | class DevelopmentConfig(BaseConfig): 14 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') 15 | BCRYPT_LOG_ROUNDS = 4 16 | 17 | 18 | class TestingConfig(BaseConfig): 19 | TESTING = True 20 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_TEST_URL') 21 | BCRYPT_LOG_ROUNDS = 4 22 | TOKEN_EXPIRATION_DAYS = 0 23 | TOKEN_EXPIRATION_SECONDS = 3 24 | 25 | 26 | class ProductionConfig(BaseConfig): 27 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') 28 | -------------------------------------------------------------------------------- /api/project/db/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:11.1-alpine 2 | 3 | ADD create.sql /docker-entrypoint-initdb.d 4 | -------------------------------------------------------------------------------- /api/project/db/create.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE prod_db; 2 | CREATE DATABASE dev_db; 3 | CREATE DATABASE test_db; 4 | -------------------------------------------------------------------------------- /api/project/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | """ 3 | Extensions setup 4 | ================ 5 | 6 | Extensions provide access to common resources of the application. 7 | 8 | Please, put new extension instantiations and initializations here. 9 | """ 10 | 11 | from flask_sqlalchemy import SQLAlchemy 12 | from flask_marshmallow import Marshmallow 13 | from flask_cors import CORS 14 | from flask_bcrypt import Bcrypt 15 | from .logging import Logging 16 | from logging.config import dictConfig 17 | 18 | 19 | logging = Logging() 20 | 21 | dictConfig({ 22 | 'version': 1, 23 | 'formatters': {'default': { 24 | 'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s', 25 | }}, 26 | 'handlers': {'wsgi': { 27 | 'class': 'logging.StreamHandler', 28 | 'formatter': 'default' 29 | }}, 30 | 'root': { 31 | 'level': 'DEBUG', 32 | 'handlers': ['wsgi'] 33 | } 34 | }) 35 | 36 | cross_origin_resource_sharing = CORS() 37 | db = SQLAlchemy() 38 | ma = Marshmallow() 39 | bcrypt = Bcrypt() 40 | 41 | 42 | def init_app(app): 43 | """ 44 | Application extensions initialization. 45 | """ 46 | for extension in ( 47 | logging, 48 | cross_origin_resource_sharing, 49 | db, 50 | ma, 51 | bcrypt 52 | ): 53 | extension.init_app(app) 54 | -------------------------------------------------------------------------------- /api/project/extensions/logging.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging adapter 3 | --------------- 4 | """ 5 | import logging 6 | 7 | 8 | class Logging(object): 9 | """ 10 | This is a helper extension, which adjusts logging configuration for the 11 | application. 12 | """ 13 | 14 | def __init__(self, app=None): 15 | if app: 16 | self.init_app(app) 17 | 18 | def init_app(self, app): 19 | """ 20 | Common Flask interface to initialize the logging according to the 21 | application configuration. 22 | """ 23 | # We don't need the default Flask's loggers when using our invoke tasks 24 | # since we set up beautiful colorful loggers globally. 25 | for handler in list(app.logger.handlers): 26 | app.logger.removeHandler(handler) 27 | app.logger.propagate = True 28 | 29 | # We don't need the default SQLAlchemy loggers when using our invoke 30 | # tasks since we set up beautiful colorful loggers globally. 31 | # NOTE: This particular workaround is for the SQLALCHEMY_ECHO mode, 32 | # when all SQL commands get printed (without these lines, they will get 33 | # printed twice). 34 | sqla_logger = logging.getLogger('sqlalchemy.engine.base.Engine') 35 | for hdlr in list(sqla_logger.handlers): 36 | sqla_logger.removeHandler(hdlr) 37 | sqla_logger.addHandler(logging.NullHandler()) 38 | -------------------------------------------------------------------------------- /api/project/modules/__init__.py: -------------------------------------------------------------------------------- 1 | from project.modules.users import users_blueprint 2 | from project.modules.auth import api as authApi 3 | 4 | 5 | def initiate_app(app, **kwargs): 6 | app.register_blueprint(users_blueprint) 7 | app.register_blueprint(authApi) 8 | -------------------------------------------------------------------------------- /api/project/modules/auth/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from flask import Blueprint, request, jsonify 3 | from http import HTTPStatus 4 | from sqlalchemy import exc, or_ 5 | from project.extensions import db, bcrypt 6 | from project.modules.utils import authenticate 7 | from project.modules.users.models import Users 8 | 9 | log = logging.getLogger(__name__) 10 | api = Blueprint('auth', __name__) 11 | 12 | 13 | @api.route('/api/v1/auth/ping', methods=['GET']) 14 | @authenticate 15 | def check_token(resp): 16 | response_object = {'status': 'success', 'message': 'Token valid'} 17 | return jsonify(response_object), HTTPStatus.OK 18 | 19 | 20 | @api.route('/api/v1/auth/status', methods=['GET']) 21 | @authenticate 22 | def get_user_status(resp): 23 | user = Users.query.filter_by(id=resp).first() 24 | response_object = { 25 | 'status': 'success', 26 | 'message': 'success', 27 | 'data': user.to_json() 28 | } 29 | return jsonify(response_object), 200 30 | 31 | 32 | @api.route('/api/v1/auth/login', methods=['POST']) 33 | def login_user(): 34 | post_data = request.get_json() 35 | response_object = {'status': 'fail', 'message': 'Invalid payload'} 36 | if not post_data: 37 | return jsonify(response_object), HTTPStatus.BAD_REQUEST 38 | 39 | username = post_data.get('username') 40 | password = post_data.get('password') 41 | try: 42 | user = Users.query.filter_by(username=username).first() 43 | if user and bcrypt.check_password_hash(user.password, password): 44 | auth_token = user.encode_auth_token(user.id) 45 | if auth_token: 46 | response_object = { 47 | 'status': 'success', 48 | 'message': 'Successfully logged in', 49 | 'auth_token': auth_token.decode() 50 | } 51 | return jsonify(response_object), HTTPStatus.OK 52 | else: 53 | response_object['message'] = 'User does not exist' 54 | return jsonify(response_object), HTTPStatus.NOT_FOUND 55 | except Exception as e: 56 | log.error(e) 57 | response_object['message'] = 'Try again.' 58 | return jsonify(response_object), HTTPStatus.INTERNAL_SERVER_ERROR 59 | 60 | 61 | @api.route('/api/v1/auth/logout', methods=['GET']) 62 | @authenticate 63 | def logout_user(resp): 64 | response_object = { 65 | 'status': 'success', 66 | 'message': 'Successfully logged out' 67 | } 68 | return jsonify(response_object), HTTPStatus.OK 69 | 70 | 71 | @api.route('/api/v1/auth/register', methods=['POST']) 72 | def register_user(): 73 | post_data = request.get_json() 74 | response_object = {'status': 'fail', 'message': 'Invalid payload'} 75 | if not post_data: 76 | return jsonify(response_object), 400 77 | 78 | username = post_data.get('username') 79 | email = post_data.get('email') 80 | password = post_data.get('password') 81 | 82 | try: 83 | user = Users.query.filter( 84 | or_(Users.username == username, Users.email == email)).first() 85 | if not user: 86 | # add new user to db 87 | new_user = Users(username=username, email=email, password=password) 88 | db.session.add(new_user) 89 | db.session.commit() 90 | 91 | # generate auth token 92 | auth_token = new_user.encode_auth_token(new_user.id) 93 | response_object['status'] = 'success' 94 | response_object['message'] = 'Successfully registered.' 95 | response_object['auth_token'] = auth_token.decode() 96 | return jsonify(response_object), 200 97 | else: 98 | response_object['message'] = 'Sorry. That user already exists.' 99 | return jsonify(response_object), 400 100 | except (exc.IntegrityError, ValueError): 101 | db.session.rollback() 102 | return jsonify(response_object), 400 103 | -------------------------------------------------------------------------------- /api/project/modules/users/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from http import HTTPStatus 3 | from flask import Blueprint, request 4 | from flask_restx import Resource, Api 5 | 6 | from project.modules.utils import authenticate_restful, is_admin 7 | from .models import Users as UsersModel 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | users_blueprint = Blueprint('users', __name__) 12 | api = Api(users_blueprint) 13 | 14 | 15 | class Users(Resource): 16 | 17 | method_decorators = {'get': [authenticate_restful]} 18 | 19 | def get(self, user, token: str): 20 | """Get single user details""" 21 | response_object = {'status': 'fail', 'message': 'User does not exist'} 22 | log.debug(token) 23 | log.debug(user) 24 | try: 25 | if not user: 26 | return response_object, HTTPStatus.NOT_FOUND 27 | else: 28 | response_object = {'status': 'success', 'data': user.to_json()} 29 | return response_object, HTTPStatus.OK 30 | except ValueError: 31 | return response_object, HTTPStatus.NOT_FOUND 32 | except Exception as e: 33 | response_object[ 34 | 'message'] = "There's an error while fetch user detail" 35 | log.error(e) 36 | return response_object, HTTPStatus.INTERNAL_SERVER_ERROR 37 | 38 | 39 | class UsersList(Resource): 40 | 41 | method_decorators = { 42 | 'get': [authenticate_restful], 43 | 'post': [authenticate_restful], 44 | 'put': [authenticate_restful] 45 | } 46 | 47 | def get(self): 48 | """Get all users""" 49 | response_object = { 50 | 'status': 'success', 51 | 'users': [user.to_json() for user in UsersModel.query.all()] 52 | } 53 | return response_object, HTTPStatus.OK 54 | 55 | def post(self, user): 56 | post_data = request.get_json() 57 | response_object = {'status': 'fail', 'message': 'Invalid payload'} 58 | if not is_admin(resp): 59 | response_object['message'] = \ 60 | "You do not have permission to do that." 61 | return response_object, HTTPStatus.UNAUTHORIZED 62 | if not post_data: 63 | return response_object, HTTPStatus.BAD_REQUEST 64 | 65 | username = post_data.get('username') 66 | email = post_data.get('email') 67 | try: 68 | response_object = { 69 | 'status': 'success', 70 | 'message': f'{email} was added!' 71 | } 72 | return response_object, HTTPStatus.CREATED 73 | except Exception as e: 74 | log.error(e) 75 | 76 | def put(self, user): 77 | post_data = request.get_json() 78 | response_object = {'status': 'fail', 'message': 'Invalid payload'} 79 | if not is_admin(resp): 80 | response_object['message'] = \ 81 | "You do not have permission to do that." 82 | return response_object, HTTPStatus.UNAUTHORIZED 83 | if not post_data: 84 | return response_object, HTTPStatus.BAD_REQUEST 85 | 86 | username = post_data.get('username') 87 | email = post_data.get('email') 88 | try: 89 | response_object = { 90 | 'status': 'success', 91 | 'message': f'{email} was added!' 92 | } 93 | return response_object, HTTPStatus.CREATED 94 | except Exception as e: 95 | log.error(e) 96 | 97 | 98 | api.add_resource(Users, '/api/v1/users/') 99 | api.add_resource(UsersList, '/api/v1/users') 100 | -------------------------------------------------------------------------------- /api/project/modules/users/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import jwt 3 | import logging 4 | from project.extensions import db, bcrypt 5 | from sqlalchemy_utils import Timestamp 6 | from flask import current_app 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class Users(db.Model, Timestamp): 12 | 13 | __tablename__ = "users" 14 | 15 | id = db.Column(db.Integer, primary_key=True, autoincrement=True) 16 | username = db.Column(db.String(128), nullable=False, unique=True) 17 | password = db.Column(db.String(255), nullable=False) 18 | email = db.Column(db.String(128), unique=True) 19 | admin = db.Column(db.Boolean, default=False, nullable=False) 20 | active = db.Column(db.Boolean, default=True, nullable=False) 21 | 22 | def __init__(self, 23 | username: str, 24 | password: str, 25 | email: str = '', 26 | admin: bool = False): 27 | self.username = username 28 | self.email = email 29 | self.admin = admin 30 | self.password = bcrypt.generate_password_hash( 31 | password, current_app.config.get('BCRYPT_LOG_ROUNDS')).decode() 32 | 33 | def to_json(self): 34 | return { 35 | 'id': self.id, 36 | 'username': self.username, 37 | 'email': self.email, 38 | 'active': self.active, 39 | 'admin': self.admin 40 | } 41 | 42 | def encode_auth_token(self, user_id: int): 43 | """Generates the auth token""" 44 | try: 45 | exp_days = current_app.config.get('TOKEN_EXPIRATION_DAYS') 46 | exp_sec = current_app.config.get('TOKEN_EXPIRATION_SECONDS') 47 | payload = { 48 | 'exp': 49 | datetime.datetime.utcnow() + 50 | datetime.timedelta(days=exp_days, seconds=exp_sec), 51 | 'iat': 52 | datetime.datetime.utcnow(), 53 | 'sub': 54 | user_id 55 | } 56 | return jwt.encode(payload, 57 | current_app.config.get('SECRET_KEY'), 58 | algorithm='HS256') 59 | except Exception as e: 60 | return e 61 | 62 | @staticmethod 63 | def decode_auth_token(auth_token: bytes): 64 | """ 65 | Decodes the auth token 66 | - :param auth_token: 67 | - :return integer|string 68 | """ 69 | try: 70 | payload = jwt.decode(auth_token, 71 | current_app.config.get('SECRET_KEY')) 72 | return payload['sub'] 73 | except jwt.ExpiredSignatureError: 74 | return 'Signature expired. Please log in again.' 75 | except jwt.InvalidTokenError: 76 | return 'Invalid token. Please log in again.' 77 | -------------------------------------------------------------------------------- /api/project/modules/utils.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | from functools import wraps 3 | from flask import request, jsonify 4 | from project.modules.users.models import Users 5 | 6 | 7 | def is_admin(user_id: int): 8 | user = Users.query.filter_by(id=user_id).first() 9 | return user.admin 10 | 11 | 12 | def authenticate(f): 13 | @wraps(f) 14 | def decorated_function(*args, **kwargs): 15 | response_object = { 16 | 'status': 'fail', 17 | 'message': 'Provide a valid auth token.' 18 | } 19 | auth_header = request.headers.get('Authorization') 20 | if not auth_header: 21 | return jsonify(response_object), HTTPStatus.FORBIDDEN 22 | 23 | auth_token = auth_header.split(" ")[1] 24 | resp = Users.decode_auth_token(auth_token) 25 | if isinstance(resp, str): 26 | response_object['message'] = resp 27 | return jsonify(response_object), HTTPStatus.UNAUTHORIZED 28 | 29 | user = Users.query.filter_by(id=resp).first() 30 | if not user or not user.active: 31 | return jsonify(response_object), HTTPStatus.UNAUTHORIZED 32 | return f(user, *args, **kwargs) 33 | 34 | return decorated_function 35 | 36 | 37 | def authenticate_restful(f): 38 | @wraps(f) 39 | def decorated_function(*args, **kwargs): 40 | response_object = { 41 | 'status': 'fail', 42 | 'message': 'Provide a valid auth token.' 43 | } 44 | auth_header = request.headers.get('Authorization') 45 | if not auth_header: 46 | return response_object, HTTPStatus.FORBIDDEN 47 | auth_token = auth_header.split(" ")[1] 48 | resp = Users.decode_auth_token(auth_token) 49 | if isinstance(resp, str): 50 | response_object['message'] = resp 51 | return response_object, HTTPStatus.UNAUTHORIZED 52 | user = Users.query.filter_by(id=resp).first() 53 | if not user or not user.active: 54 | return response_object, HTTPStatus.UNAUTHORIZED 55 | return f(user, *args, **kwargs) 56 | 57 | return decorated_function 58 | -------------------------------------------------------------------------------- /api/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | docker-compose==1.25.4 4 | jedi==0.15.0 5 | python-language-server==0.31.9 6 | yapf==0.29.0 7 | pylint==2.4.4 8 | pylama==7.7.1 9 | pip-review==1.1.0 10 | -------------------------------------------------------------------------------- /api/requirements.txt: -------------------------------------------------------------------------------- 1 | coverage==5.0.4 2 | Flask==1.1.2 3 | Flask-SQLAlchemy==2.4.1 4 | psycopg2-binary==2.8.5 5 | gunicorn==20.0.4 6 | flask-bcrypt==0.7.1 7 | flask-cors==3.0.8 8 | flask-marshmallow==0.11.0 9 | flask-migrate==2.5.3 10 | flask-restx==0.2.0 11 | Flask-Testing==0.8.0 12 | marshmallow-sqlalchemy==0.22.3 13 | pyjwt==1.7.1 14 | requests==2.23.0 15 | SQLAlchemy-Utils==0.36.3 16 | -------------------------------------------------------------------------------- /api/setup.cfg: -------------------------------------------------------------------------------- 1 | [pylama:pycodestyle] 2 | max_line_length = 120 3 | 4 | [pylama:pylint] 5 | max_line_length = 120 6 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | node_modules/ 3 | Dockerfile* 4 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | # Logs 3 | logs 4 | *.log 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Stores VSCode versions used for testing VSCode extensions 108 | .vscode-test 109 | 110 | 111 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10.16.0-alpine 2 | 3 | WORKDIR /usr/src/app 4 | ENV PATH /usr/src/app/node_modules/.bin:$PATH 5 | 6 | # install and cache app dependencies 7 | COPY package.json /usr/src/app/package.json 8 | 9 | RUN npm install 10 | 11 | EXPOSE 3000 12 | CMD ["npm", "run", "dev"] 13 | -------------------------------------------------------------------------------- /client/Dockerfile-prod: -------------------------------------------------------------------------------- 1 | # Do the npm install or yarn install in the full image 2 | FROM mhart/alpine-node:12 AS builder 3 | WORKDIR /usr/src/app 4 | 5 | ENV PATH /usr/src/app/node_modules/.bin:$PATH 6 | COPY package.json /usr/src/app/package.json 7 | 8 | RUN npm install 9 | 10 | ARG REACT_APP_SERVICE_URL 11 | ENV REACT_APP_SERVICE_URL $REACT_APP_SERVICE_URL 12 | ARG NODE_ENV 13 | ENV NODE_ENV $NODE_ENV 14 | 15 | COPY . /usr/src/app 16 | RUN npm run build --production 17 | 18 | ######### 19 | # FINAL # 20 | ######### 21 | 22 | FROM mhart/alpine-node:base 23 | WORKDIR /app 24 | COPY --from=builder /usr/src/app . 25 | EXPOSE 3000 26 | CMD ["node_modules/.bin/next", "start"] 27 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # With Docker 2 | 3 | ## How to use 4 | 5 | ### Using `create-next-app` 6 | 7 | Execute [`create-next-app`](https://github.com/segmentio/create-next-app) with [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) or [npx](https://github.com/zkat/npx#readme) to bootstrap the example: 8 | 9 | ```bash 10 | npx create-next-app --example with-docker with-docker-app 11 | # or 12 | yarn create next-app --example with-docker with-docker-app 13 | ``` 14 | 15 | ### Download manually 16 | 17 | Download the example: 18 | 19 | ```bash 20 | curl https://codeload.github.com/zeit/next.js/tar.gz/canary | tar -xz --strip=2 next.js-canary/examples/with-docker 21 | cd with-docker 22 | ``` 23 | 24 | Build it with docker: 25 | 26 | ```bash 27 | # build 28 | docker build -t next-app . 29 | # or, use multi-stage builds to build a smaller docker image 30 | docker build -t next-app -f ./Dockerfile.multistage . 31 | ``` 32 | 33 | Run it: 34 | 35 | ```bash 36 | docker run --rm -it \ 37 | -p 3000:3000 \ 38 | -e "API_URL=https://example.com" \ 39 | next-app 40 | ``` 41 | 42 | Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download)) 43 | 44 | ```bash 45 | now --docker -e API_URL="https://example.com" 46 | ``` 47 | 48 | ## The idea behind the example 49 | 50 | This example show how to set custom environment variables for your **docker application** at runtime. 51 | 52 | The `dockerfile` is the simplest way to run Next.js app in docker, and the size of output image is `173MB`. However, for an even smaller build, you can do multi-stage builds with `dockerfile.multistage`. The size of output image is `85MB`. 53 | 54 | You can check the [Example Dockerfile for your own Node.js project](https://github.com/mhart/alpine-node/tree/43ca9e4bc97af3b1f124d27a2cee002d5f7d1b32#example-dockerfile-for-your-own-nodejs-project) section in [mhart/alpine-node](https://github.com/mhart/alpine-node) for more details. 55 | -------------------------------------------------------------------------------- /client/components/error-boundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | export class ErrorBoundary extends Component { 4 | state = { 5 | error: null, 6 | eventId: null 7 | }; 8 | 9 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 10 | this.setState({ error }); 11 | console.log(error, errorInfo); 12 | } 13 | 14 | render() { 15 | if (this.state.error) { 16 | return
Error
; 17 | } else { 18 | return this.props.children; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/components/errors/five_hundred_error.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import Link from "next/link"; 4 | 5 | const Container = styled.div` 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | height: 100vh; 10 | overflow: hidden; 11 | flex-direction: column; 12 | `; 13 | 14 | export const FiveHundredError = () => { 15 | return ( 16 | 17 |
18 | 195 |
196 |
197 |
198 | 199 | Go back to Home 200 | 201 |
202 |
203 | ); 204 | }; 205 | -------------------------------------------------------------------------------- /client/components/errors/four_zero_four_error.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import Link from "next/link"; 4 | 5 | const Container = styled.div` 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | height: 100vh; 10 | overflow: hidden; 11 | flex-direction: column; 12 | 13 | .error-code { 14 | &:after { 15 | content: "Looks like you are lost!" !important; 16 | } 17 | } 18 | `; 19 | 20 | export const FourZeroFour = () => { 21 | return ( 22 | 23 |
24 | 189 |
190 |
191 |
192 | The page you are looking for might have been removed had its name 193 | changed or is temporarily unavailable. 194 |
195 | 196 | Back to homepage 197 | 198 |
199 |
200 | ); 201 | }; 202 | -------------------------------------------------------------------------------- /client/components/errors/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./five_hundred_error"; 2 | export * from "./four_zero_four_error"; 3 | -------------------------------------------------------------------------------- /client/components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const CustomFooter = styled.footer` 5 | position: absolute; 6 | bottom: 0; 7 | width: 100%; 8 | height: 60px; 9 | line-height: 60px; 10 | background-color: #f5f5f5; 11 | `; 12 | 13 | export const Footer = () => { 14 | return ( 15 | 16 |
17 | 18 | © 19 | {`${new Date().getFullYear()} Company Inc. All Rights Reserved`} 20 | 21 |
22 | 27 | Github 28 | 29 |
30 |
31 |
32 | ); 33 | }; 34 | 35 | Footer.displayName = "Footer"; 36 | -------------------------------------------------------------------------------- /client/components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Collapse, Navbar, NavbarToggler, NavbarBrand } from "reactstrap"; 3 | import { Menu } from "./menu"; 4 | 5 | export const Header = () => { 6 | const [isOpen, setIsOpen] = useState(false); 7 | 8 | const toggle = () => { 9 | setIsOpen(!isOpen); 10 | }; 11 | return ( 12 |
13 |
14 | 15 | Flask/NextJs App 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /client/components/layout/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./header"; 2 | export * from "./footer"; 3 | export * from "./layout"; 4 | -------------------------------------------------------------------------------- /client/components/layout/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ErrorBoundary } from "../error-boundary"; 3 | import { Header, Footer } from "components/layout"; 4 | 5 | export const Layout = props => { 6 | return ( 7 | <> 8 |
9 | 10 |
{props.children}
11 |
12 |