├── .env.example ├── .github ├── FUNDING.yml └── workflows │ ├── build-and-test.yml │ ├── codeql-analysis.yml │ ├── deploy-bare-metal.yml │ ├── deploy-vm.yml │ ├── docker-publish.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .releaserc ├── CHANGELOG.md ├── Dockerfile ├── LICENCE ├── README.md ├── api ├── __init__.py ├── apps.py ├── filters │ ├── __init__.py │ └── publications.py ├── migrations │ └── __init__.py ├── serializers │ ├── __init__.py │ ├── publications.py │ └── subscribers.py ├── tests.py ├── urls.py ├── utils.py └── views │ ├── __init__.py │ ├── publications.py │ └── subscribers.py ├── assets └── Logo.png ├── backend ├── __init__.py ├── actions.py ├── admin │ ├── __init__.py │ ├── publications.py │ └── subscribers.py ├── apps.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── clearcache.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_create_default_superuser.py │ └── __init__.py ├── models │ ├── __init__.py │ ├── base.py │ ├── publications.py │ └── subscribers.py ├── signals.py ├── static │ └── backend │ │ ├── logo.png │ │ └── subscribers.js ├── templates │ └── backend │ │ └── email.html ├── tests.py └── utils.py ├── config ├── certs │ └── certbot.sh ├── nginx │ └── www.example.com └── supervisor │ └── drt.ini ├── core ├── __init__.py ├── asgi.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── dev.py │ └── prod.py ├── urls.py └── wsgi.py ├── docker-compose-dev-db.yml ├── docker-compose.yml ├── frontend ├── .env.example ├── .unimportedrc.json ├── __init__.py ├── apps.py ├── babel.config.js ├── declarations.d.ts ├── index.tsx ├── lib │ ├── api │ │ ├── index.ts │ │ ├── types.ts │ │ ├── use-api.ts │ │ └── utils.ts │ ├── components │ │ ├── blog-header.tsx │ │ ├── blog-post-preview.tsx │ │ ├── blog-preview-section.tsx │ │ ├── full-screen-loading.tsx │ │ ├── getting-started-section.tsx │ │ └── topbar.tsx │ ├── config.ts │ ├── hooks │ │ ├── index.ts │ │ └── use-debounce.ts │ ├── index.tsx │ ├── pages │ │ ├── blog.tsx │ │ ├── landing.tsx │ │ └── publication.tsx │ └── routes │ │ ├── index.ts │ │ ├── routes.ts │ │ └── use-router.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ ├── logo-32.png │ ├── logo-512.png │ ├── manifest.json │ └── robots.txt ├── tailwind.config.js ├── tailwind.css ├── templates │ └── frontend │ │ ├── dev │ │ └── index.html │ │ └── index.html ├── tests.py ├── tsconfig.json ├── tslint.json ├── urls.py ├── views.py └── webpack.config.js ├── manage.py ├── package.json ├── pnpm-lock.yaml ├── poetry.lock ├── pyproject.toml ├── ruff.toml └── scripts └── setup_env.sh /.env.example: -------------------------------------------------------------------------------- 1 | # Copy this file to .env and fill in the values 2 | 3 | # Generate a secrety key for Django and set it here. 4 | # You can use the following command to generate a secret key: 5 | # python3 -c "import secrets; print(secrets.token_urlsafe())" 6 | SECRET_KEY= 7 | 8 | # Set the following variables to the values of your database 9 | DB_HOST= 10 | DB_NAME= 11 | DB_USER= 12 | DB_PASSWORD= 13 | DB_PORT= 14 | 15 | # Set the following variables to the values of your Cloudinary account 16 | CDN_NAME= 17 | CDN_API_KEY= 18 | CDN_API_SECRET= 19 | 20 | # Set the following variables to the values of your SMTP server 21 | SMTP_HOST_USER= 22 | SMTP_HOST_PASSWORD= 23 | 24 | # Wether to run the application in test mode or not 25 | TEST= 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: marcelovicentegc 4 | custom: 'https://www.buymeacoffee.com/YkwcZVO' 5 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build frontend, backend and Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | jobs: 9 | build-frontend: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - uses: pnpm/action-setup@v4 15 | name: Install pnpm 16 | with: 17 | version: 8 18 | run_install: false 19 | - name: Install Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: "pnpm" 24 | - name: Get pnpm store directory 25 | shell: bash 26 | run: | 27 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 28 | - uses: actions/cache@v4 29 | name: Setup pnpm cache 30 | with: 31 | path: ${{ env.STORE_PATH }} 32 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 33 | restore-keys: | 34 | ${{ runner.os }}-pnpm-store- 35 | - name: Install dependencies 36 | working-directory: ./frontend 37 | run: pnpm install 38 | - name: Build frontend 39 | working-directory: ./frontend 40 | run: pnpm build 41 | 42 | build-backend: 43 | runs-on: ubuntu-latest 44 | needs: build-frontend 45 | steps: 46 | - uses: actions/checkout@v4 47 | - name: Set up Python 48 | uses: actions/setup-python@v4 49 | with: 50 | python-version: 3.12 51 | - name: Install dependencies 52 | run: | 53 | python -m pip install --upgrade pip 54 | pip install poetry 55 | poetry install --no-dev 56 | - name: Run Tests 57 | env: 58 | TEST: 1 59 | run: | 60 | poetry run python manage.py test 61 | 62 | test-image: 63 | runs-on: ubuntu-latest 64 | needs: build-backend 65 | env: 66 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 67 | steps: 68 | - uses: actions/checkout@v4 69 | - name: Test Docker build 70 | run: | 71 | docker build . --build-arg AUTH_TOKEN=${{ secrets.AUTH_KEY }} --file Dockerfile 72 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [main] 14 | schedule: 15 | - cron: "0 21 * * 4" 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ["javascript", "python"] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/deploy-bare-metal.yml: -------------------------------------------------------------------------------- 1 | name: Login into host machine, build and start the app's daemon 2 | on: 3 | push: 4 | branches: 5 | - bare-metal-deploy 6 | 7 | jobs: 8 | build-frontend: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | - uses: pnpm/action-setup@v4 14 | name: Install pnpm 15 | with: 16 | version: 8 17 | run_install: false 18 | - name: Install Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 20 22 | cache: "pnpm" 23 | - name: Get pnpm store directory 24 | shell: bash 25 | run: | 26 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 27 | - uses: actions/cache@v4 28 | name: Setup pnpm cache 29 | with: 30 | path: ${{ env.STORE_PATH }} 31 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 32 | restore-keys: | 33 | ${{ runner.os }}-pnpm-store- 34 | - name: Install dependencies 35 | working-directory: ./frontend 36 | run: pnpm install 37 | - name: Build frontend 38 | working-directory: ./frontend 39 | run: pnpm build 40 | 41 | build-backend: 42 | runs-on: ubuntu-latest 43 | needs: build-frontend 44 | steps: 45 | - uses: actions/checkout@v4 46 | - name: Set up Python 47 | uses: actions/setup-python@v4 48 | with: 49 | python-version: 3.12 50 | - name: Install dependencies 51 | run: | 52 | python -m pip install --upgrade pip 53 | pip install poetry 54 | poetry install --no-dev 55 | - name: Run Tests 56 | env: 57 | TEST: 1 58 | run: | 59 | poetry run python manage.py test 60 | 61 | deploy: 62 | runs-on: ubuntu-latest 63 | needs: build-django 64 | steps: 65 | - uses: actions/checkout@master 66 | - name: copy repo to host machine 67 | uses: appleboy/scp-action@master 68 | with: 69 | host: ${{ secrets.HOST }} 70 | username: ${{ secrets.USERNAME }} 71 | password: ${{ secrets.SSH_PASSWORD }} 72 | port: 22 73 | source: "poetry.lock,pyproject.toml,api,backend,config,core,frontend,manage.py" 74 | target: "app_to_deploy" 75 | 76 | - name: build the backend, the frontend and start the app's daemon 77 | uses: appleboy/ssh-action@master 78 | with: 79 | host: ${{ secrets.HOST }} 80 | username: ${{ secrets.USERNAME }} 81 | password: ${{ secrets.SSH_PASSWORD }} 82 | port: 22 83 | script: | 84 | echo -e "======================== HOST DEPLOY STARTED ========================\n" 85 | 86 | cd app_to_deploy 87 | echo -e "~~~~~~~~~~~~~~~~~~~~~~~~ Changed directory into app_to_deploy ~~~~~~~~~~~~~~~~~~~~~~~~\n" 88 | 89 | cd frontend 90 | echo -e "~~~~~~~~~~~~~~~~~~~~~~~~ Changed directory into frontend ~~~~~~~~~~~~~~~~~~~~~~~~\n" 91 | 92 | npm ci 93 | echo -e "~~~~~~~~~~~~~~~~~~~~~~~~ Installed javascript dependencies successfully ~~~~~~~~~~~~~~~~~~~~~~~~\n" 94 | 95 | rm .env 96 | touch .env 97 | echo 'NODE_ENV=production' >> .env 98 | echo 'AUTH_TOKEN='${{ secrets.AUTH_KEY }} >> .env 99 | echo 'GTAG_ID='${{ secrets.GTAG_ID }} >> .env 100 | 101 | npx webpack 102 | echo -e "~~~~~~~~~~~~~~~~~~~~~~~~ Built frontend successfully ~~~~~~~~~~~~~~~~~~~~~~~~\n" 103 | 104 | cd .. 105 | rm .env 106 | touch .env 107 | echo 'MODE=production' >> .env 108 | echo 'SECRET_KEY='${{ secrets.SECRET_KEY }} >> .env 109 | echo 'CDN_NAME='${{ secrets.CDN_NAME }} >> .env 110 | echo 'CDN_API_KEY='${{ secrets.CDN_API_KEY }} >> .env 111 | echo 'CDN_API_SECRET='${{ secrets.CDN_API_SECRET }} >> .env 112 | echo 'DB_HOST='${{ secrets.DB_HOST }} >> .env 113 | echo 'DB_NAME='${{ secrets.NAME }} >> .env 114 | echo 'DB_USER=${{ secrets.DB_USER }}' >> .env 115 | echo 'DB_PASSWORD=${{ secrets.DB_PASSWORD }}' >> .env 116 | echo 'SMTP_HOST_USER='${{ secrets.SMTP_HOST_USER }} >> .env 117 | echo 'SMTP_HOST_PASSWORD='${{ secrets.SMTP_HOST_PASSWORD }} >> .env 118 | 119 | cd .. 120 | cp -rf --no-target-directory app_to_deploy app 121 | echo -e "~~~~~~~~~~~~~~~~~~~~~~~~ Replaced current running code with new one ~~~~~~~~~~~~~~~~~~~~~~~~\n" 122 | 123 | cd app 124 | python3 -m pip install --upgrade pip 125 | pip install poetry 126 | poetry install --no-dev 127 | echo -e "~~~~~~~~~~~~~~~~~~~~~~~~ Installed python requirements successfully ~~~~~~~~~~~~~~~~~~~~~~~~\n" 128 | 129 | poetry run python3 manage.py migrate 130 | poetry run python3 manage.py collectstatic --no-input 131 | poetry run python3 manage.py clearcache 132 | 133 | echo ${{ secrets.SSH_PASSWORD }} | sudo -S rm /etc/supervisord.d/drt.ini 134 | echo ${{ secrets.SSH_PASSWORD }} | sudo -S cp ./config/supervisor/drt.ini /etc/supervisord.d/ 135 | echo ${{ secrets.SSH_PASSWORD }} | sudo -S supervisorctl update 136 | echo ${{ secrets.SSH_PASSWORD }} | sudo -S supervisorctl reread 137 | echo ${{ secrets.SSH_PASSWORD }} | sudo -S supervisorctl status drt 138 | 139 | rm .env 140 | echo -e "~~~~~~~~~~~~~~~~~~~~~~~~ Gunicorn daemon is up ~~~~~~~~~~~~~~~~~~~~~~~~\n" 141 | echo -e "======================== HOST DEPLOY IS DONE ========================\n" 142 | -------------------------------------------------------------------------------- /.github/workflows/deploy-vm.yml: -------------------------------------------------------------------------------- 1 | name: Build image, push to registry and deploy to Digital Ocean's production environment 2 | 3 | on: 4 | push: 5 | # Publish `prd` as Docker `latest` image. 6 | branches: 7 | - vm-deploy 8 | 9 | # Publish `v1.2.3` tags as releases. 10 | tags: 11 | - v* 12 | 13 | # Run tests for any PRs. 14 | pull_request: 15 | 16 | jobs: 17 | build-frontend: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | name: Install pnpm 24 | with: 25 | version: 8 26 | run_install: false 27 | - name: Install Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: "pnpm" 32 | - name: Get pnpm store directory 33 | shell: bash 34 | run: | 35 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 36 | - uses: actions/cache@v4 37 | name: Setup pnpm cache 38 | with: 39 | path: ${{ env.STORE_PATH }} 40 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pnpm-store- 43 | - name: Install dependencies 44 | working-directory: ./frontend 45 | run: pnpm install 46 | - name: Build frontend 47 | working-directory: ./frontend 48 | run: pnpm build 49 | 50 | build-backend: 51 | runs-on: ubuntu-latest 52 | needs: build-frontend 53 | steps: 54 | - uses: actions/checkout@v4 55 | - name: Set up Python 56 | uses: actions/setup-python@v4 57 | with: 58 | python-version: 3.12 59 | - name: Install dependencies 60 | run: | 61 | python -m pip install --upgrade pip 62 | pip install poetry 63 | poetry install --no-dev 64 | - name: Run Tests 65 | env: 66 | TEST: 1 67 | run: | 68 | poetry run python manage.py test 69 | 70 | test-image: 71 | runs-on: ubuntu-latest 72 | needs: build-backend 73 | env: 74 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 75 | steps: 76 | - uses: actions/checkout@v4 77 | - name: Run tests 78 | run: | 79 | docker build . --build-arg AUTH_TOKEN=${{ secrets.AUTH_KEY }}--build-arg ALLOWED_HOSTS=${{ secrets.ALLOWED_HOSTS }} --file Dockerfile 80 | 81 | # Push image to GitHub Packages. 82 | # See also https://docs.docker.com/docker-hub/builds/ 83 | push-image-to-registry: 84 | # Ensure test job passes before pushing image. 85 | needs: test-image 86 | runs-on: ubuntu-latest 87 | if: github.event_name == 'push' 88 | steps: 89 | - uses: actions/checkout@v4 90 | - name: Build image 91 | run: docker build . --build-arg AUTH_TOKEN=${{ secrets.AUTH_KEY }} --build-arg ALLOWED_HOSTS=${{ secrets.ALLOWED_HOSTS }} --file Dockerfile --tag ${{ secrets.IMAGE_NAME }} 92 | - name: Log into registry 93 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin 94 | - name: Push image 95 | run: | 96 | IMAGE_ID=docker.pkg.github.com/${{ github.repository }}/${{ secrets.IMAGE_NAME }} 97 | 98 | # Change all uppercase to lowercase 99 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 100 | 101 | # Strip git ref prefix from version 102 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 103 | 104 | # Strip "v" prefix from tag name 105 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 106 | 107 | # Use Docker `latest` tag convention 108 | [ "$VERSION" == "prd" ] && VERSION=latest 109 | 110 | echo IMAGE_ID=$IMAGE_ID 111 | echo VERSION=$VERSION 112 | 113 | docker tag ${{ secrets.IMAGE_NAME }} $IMAGE_ID:$VERSION 114 | docker push $IMAGE_ID:$VERSION 115 | 116 | deploy: 117 | needs: push-image-to-registry 118 | runs-on: ubuntu-latest 119 | steps: 120 | - uses: actions/checkout@vm-deploy 121 | - name: copy docker-compose.yml 122 | uses: appleboy/scp-action@vm-deploy 123 | with: 124 | host: ${{ secrets.HOST }} 125 | username: ${{ secrets.USERNAME }} 126 | key: ${{ secrets.SSH_PRIVATE_KEY }} 127 | port: 22 128 | source: "docker-compose.yml" 129 | target: "image" 130 | 131 | - name: execute docker-compose 132 | uses: appleboy/ssh-action@vm-deploy 133 | env: 134 | SECRET_KEY: ${{ secrets.SECRET_KEY }} 135 | CDN_NAME: ${{ secrets.CDN_NAME }} 136 | CDN_API_KEY: ${{ secrets.CDN_API_KEY }} 137 | CDN_API_SECRET: ${{ secrets.CDN_API_SECRET }} 138 | DB_HOST: ${{ secrets.DB_HOST }} 139 | DB_NAME: ${{ secrets.DB_NAME }} 140 | DB_USER: ${{ secrets.DB_USER }} 141 | DB_PASSWORD: ${{ secrets.DB_PASSWORD }} 142 | DB_PORT: ${{ secrets.DB_PORT }} 143 | with: 144 | host: ${{ secrets.HOST }} 145 | username: ${{ secrets.USERNAME }} 146 | key: ${{ secrets.SSH_PRIVATE_KEY }} 147 | port: 22 148 | script: | 149 | docker login -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.DEPLOY_TOKEN }} docker.pkg.github.com 150 | cd image 151 | docker-compose pull 152 | MODE=production SMTP_HOST_USER=${{ secrets.SMTP_HOST_USER }} SMTP_HOST_PASSWORD=${{ secrets.SMTP_HOST_PASSWORD }} SECRET_KEY=${{ secrets.SECRET_KEY }} CDN_NAME=${{ secrets.CDN_NAME }} CDN_API_KEY=${{ secrets.CDN_API_KEY }} CDN_API_SECRET=${{ secrets.CDN_API_SECRET }} DB_HOST=${{ secrets.DB_HOST }} DB_NAME=${{ secrets.DB_NAME }} DB_USER=${{ secrets.DB_USER }} DB_PASSWORD=${{ secrets.DB_PASSWORD }} DB_PORT=${{ secrets.DB_PORT }} docker-compose up -d 153 | docker image prune -f 154 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image to GitHub Packages 2 | 3 | on: 4 | push: 5 | # Publish `main` as Docker `latest` image. 6 | branches: 7 | - main 8 | 9 | # Publish `v1.2.3` tags as releases. 10 | tags: 11 | - v* 12 | 13 | # Run tests for any PRs. 14 | pull_request: 15 | 16 | env: 17 | # Change variable to your image's name. 18 | IMAGE_NAME: django-react-typescript 19 | 20 | jobs: 21 | # Run tests. 22 | # See also https://docs.docker.com/docker-hub/builds/automated-testing/ 23 | test: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | 29 | - name: Run tests 30 | run: | 31 | if [ -f docker-compose.test.yml ]; then 32 | docker-compose --file docker-compose.test.yml build 33 | docker-compose --file docker-compose.test.yml run sut 34 | else 35 | docker build . --file Dockerfile 36 | fi 37 | 38 | # Push image to GitHub Packages. 39 | # See also https://docs.docker.com/docker-hub/builds/ 40 | push: 41 | # Ensure test job passes before pushing image. 42 | needs: test 43 | 44 | runs-on: ubuntu-latest 45 | if: github.event_name == 'push' 46 | 47 | steps: 48 | - uses: actions/checkout@v4 49 | 50 | - name: Build image 51 | run: docker build . --file Dockerfile --tag $IMAGE_NAME 52 | 53 | - name: Log into registry 54 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u ${{ github.actor }} --password-stdin 55 | 56 | - name: Push image 57 | run: | 58 | IMAGE_ID=docker.pkg.github.com/${{ github.repository }}/$IMAGE_NAME 59 | 60 | # Change all uppercase to lowercase 61 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 62 | 63 | # Strip git ref prefix from version 64 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 65 | 66 | # Strip "v" prefix from tag name 67 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 68 | 69 | # Use Docker `latest` tag convention 70 | [ "$VERSION" == "main" ] && VERSION=latest 71 | 72 | echo IMAGE_ID=$IMAGE_ID 73 | echo VERSION=$VERSION 74 | 75 | docker tag $IMAGE_NAME $IMAGE_ID:$VERSION 76 | docker push $IMAGE_ID:$VERSION 77 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release workflow 2 | 3 | permissions: 4 | contents: write # to be able to publish a GitHub release 5 | issues: write # to be able to comment on released issues 6 | pull-requests: write # to be able to comment on released pull requests 7 | id-token: write # to enable use of OIDC for npm provenance 8 | 9 | on: 10 | push: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | release: 16 | name: release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Install Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | 27 | - uses: pnpm/action-setup@v4 28 | name: Install pnpm 29 | with: 30 | version: 8 31 | run_install: false 32 | 33 | - name: Install dependencies 34 | run: pnpm install 35 | 36 | - name: Publish 37 | run: pnpm release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | HUSKY: 0 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtual environment 2 | venv 3 | 4 | # Python 5 | __pycache__ 6 | 7 | # Django 8 | db.sqlite3 9 | .env 10 | core/static 11 | core/media 12 | 13 | # Webpack 14 | frontend/static/frontend 15 | 16 | # Tailwind 17 | frontend/lib/index.css 18 | 19 | # npm 20 | node_modules 21 | 22 | # Jest 23 | coverage 24 | 25 | # Debug 26 | .debug 27 | 28 | # VS Code 29 | .vscode 30 | 31 | # Ionide 32 | .ionide 33 | 34 | # IntelliJ 35 | .idea 36 | 37 | # Ruff 38 | .ruff_cache -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | echo "Running static tests..." 2 | pnpm run test:static 3 | echo "Done running static tests." 4 | 5 | echo "Formatting code..." 6 | pnpm run format 7 | echo "Done formatting code." 8 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/commit-analyzer", 7 | "@semantic-release/release-notes-generator", 8 | "@semantic-release/changelog", 9 | "@semantic-release/npm", 10 | "@semantic-release/git", 11 | "@semantic-release/github" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.1](https://github.com/marcelovicentegc/django-react-typescript/compare/v1.0.0...v1.0.1) (2024-07-14) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * export subscriber and publication models ([7874f48](https://github.com/marcelovicentegc/django-react-typescript/commit/7874f48ae6653eb3dddd187f05076af4824e16d1)) 7 | 8 | # 1.0.0 (2024-06-25) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * **api:** bio response ([ef6dcd8](https://github.com/marcelovicentegc/django-react-typescript/commit/ef6dcd8e7e27b86516874628bb6a06d5673836b4)) 14 | * **cicd:** fix backend test cmd ([95056d6](https://github.com/marcelovicentegc/django-react-typescript/commit/95056d6dee9d57011d1cd054af6c081adc01f247)) 15 | * **cicd:** reference to unexisting step ([b50256b](https://github.com/marcelovicentegc/django-react-typescript/commit/b50256b02cf40f3d5fc272ce735c6566a40624ca)) 16 | * remove unused api view ([aac202f](https://github.com/marcelovicentegc/django-react-typescript/commit/aac202fb180295b82b9ad783cb2794cc82871383)) 17 | 18 | 19 | ### Features 20 | 21 | * add backend app ([2b5a19e](https://github.com/marcelovicentegc/django-react-typescript/commit/2b5a19e18163834e84f4e822415a27cab40a48eb)) 22 | * add guidelines to landing page ([fb66a5d](https://github.com/marcelovicentegc/django-react-typescript/commit/fb66a5d05a50e0fefff75f10b67671a4ab9d241f)) 23 | * add publications model ([486fce4](https://github.com/marcelovicentegc/django-react-typescript/commit/486fce48d9351bb10167e6c6cd0a9a9618b2205b)) 24 | * add sentry ([ba2c11a](https://github.com/marcelovicentegc/django-react-typescript/commit/ba2c11a8a1c790a07c1ce00a01e342d2087edce0)) 25 | * create proxy to serve lazy loaded assets ([c983ac4](https://github.com/marcelovicentegc/django-react-typescript/commit/c983ac4a2ae13ae629a59194a7f1e42d963b8c1b)) 26 | * default superuser migration ([ce58974](https://github.com/marcelovicentegc/django-react-typescript/commit/ce58974c3584bcb6023d8d2a7091716f5eb4c1b5)) 27 | * frontend and rest framework apps ([598d4a4](https://github.com/marcelovicentegc/django-react-typescript/commit/598d4a4275bb348fdacf58c34a7a5936d6fb66ef)) 28 | * **frontend:** add publication page ([c717beb](https://github.com/marcelovicentegc/django-react-typescript/commit/c717bebf678140c5bfcdb77efefe128a9d449543)) 29 | * include rtc ([698f94b](https://github.com/marcelovicentegc/django-react-typescript/commit/698f94be1c61ce2c6c3e2b4d548767dd0d35ef4b)) 30 | * rest framework ([9a1fcec](https://github.com/marcelovicentegc/django-react-typescript/commit/9a1fcec91d23156a60c7f887cb63c1e174fc92ff)) 31 | * routing ([36283f4](https://github.com/marcelovicentegc/django-react-typescript/commit/36283f4449ac78f80b08f6b4ac68aec9d14bf924)) 32 | * setup tailwind ([7ac38a9](https://github.com/marcelovicentegc/django-react-typescript/commit/7ac38a9ef6553af16ce350d6d7386ec35814af3c)) 33 | * subscribers and biography models ([7dd911e](https://github.com/marcelovicentegc/django-react-typescript/commit/7dd911e59a735b334cb5d915d9e106cf8bfc0f5a)) 34 | * use unfold admin template ([a3179ff](https://github.com/marcelovicentegc/django-react-typescript/commit/a3179ffc1c3f0e20b3c0d09e27015e3eb3fce4f8)) 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build frontend 2 | FROM node:20 3 | WORKDIR /usr/src/app 4 | ARG AUTH_TOKEN 5 | ENV AUTH_TOKEN $AUTH_TOKEN 6 | ENV NODE_ENV production 7 | ADD ./frontend/ /usr/src/app/frontend/ 8 | RUN npm install -g pnpm 9 | RUN cd frontend \ 10 | && pnpm install \ 11 | && echo $'NODE_ENV=production\nAUTH_TOKEN='$AUTH_TOKEN >> .env \ 12 | && pnpm run build 13 | 14 | # Build backend 15 | FROM python:3.12-rc-slim-buster 16 | WORKDIR /usr/src/app 17 | ARG ALLOWED_HOSTS 18 | ENV ALLOWED_HOSTS $ALLOWED_HOSTS 19 | ENV PYTHONDONTWRITEBYTECODE 0 20 | ENV PYTHONUNBUFFERED 0 21 | ENV MODE "production" 22 | RUN apt-get update && \ 23 | apt-get install --no-install-recommends -y build-essential postgresql-common libpq-dev && \ 24 | apt-get clean && rm -rf /var/lib/apt/lists/* 25 | RUN pip install --upgrade pip 26 | RUN pip install poetry 27 | COPY ./pyproject.toml /usr/src/app/pyproject.toml 28 | COPY ./poetry.lock /usr/src/app/poetry.lock 29 | RUN poetry install --no-dev 30 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2020 Marcelo Cardoso 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-react-typescript 2 | 3 | django-react-typescript logo 4 | 5 | This is an non-opinionated Django 5 + React 18 boilerplate built with great development experience and easy deployment in mind. 6 | 7 | This template is ideal if you want to bootstrap a blog or a portfolio website quickly, or even a more complex application that requires a CMS, all while leveraging the best from React and Django. 8 | 9 | --- 10 | 11 | - [Getting started](#getting-started) 12 | - [Setting up a database](#setting-up-a-database) 13 | - [Setting up a CDN](#setting-up-a-cdn) 14 | - [Running the project](#running-the-project) 15 | - [Application architecture](#application-architecture) 16 | - [Features](#features) 17 | - [Going to production: infrastructure \& deployment](#going-to-production-infrastructure--deployment) 18 | - [Virtualized Deploy Workflow](#virtualized-deploy-workflow) 19 | - [Bare-metal Deploy Workflow](#bare-metal-deploy-workflow) 20 | - [Configuration](#configuration) 21 | - [Architecture overview](#architecture-overview) 22 | - [Similar projects](#similar-projects) 23 | 24 | ## Getting started 25 | 26 | This project relies on [pnpm](https://pnpm.io/) and [Poetry](https://python-poetry.org/) to manage Node.js and Python dependencies, respectively. Make sure to have both installed on your machine before proceeding. 27 | 28 | After cloning this project, install all dependencies by running: 29 | 30 | ```sh 31 | pnpm run bootstrap 32 | ``` 33 | 34 | This command will install all dependencies for the frontend (React) and backend (Django) apps. 35 | 36 | ### Setting up a database 37 | 38 | To start developing on this project, you will need a Postgres database instance running. It doesn 't matter if it's a local instance or a remote one. Just make sure to set up a Postgres database and configure the `.env` file with the correct credentials. 39 | 40 | For convenience, if you want to use Docker + Docker Compose to spin up a Postgres instance locally, with pgAdmin using alongisde, use the following command: 41 | 42 | ```sh 43 | pnpm run dev:db:up 44 | ``` 45 | 46 | ### Setting up a CDN 47 | 48 | This project uses Cloudinary as a CDN, so you will need to have an account on Cloudinary and set up the `.env` file with the correct credentials. Use the [`.env.example`](./.env.example) file as a reference. 49 | 50 | Feel free to open an issue if you want to use another CDN, and I'll be happy to help you set it up. 51 | 52 | ### Running the project 53 | 54 | Once you've set up the database, you can start the project by running one of: 55 | 56 | ```sh 57 | pnpm dev # Starts the project while assuming you've setup a database not using the Docker Compose setup. Spins up only the backend and frontend apps 58 | pnpm dev:full # Starts the project while assuming you've setup a database using the Docker Compose setup. Spins up a Postgres instance and pgAdmin alongside the backend and frontend apps 59 | ``` 60 | 61 | By default, the frontend app will run on `localhost:4000` and the backend app will run on `localhost:8000`. If you're running the containerized Postgres, it will run on `localhost:5432` and pgAdmin will run on `localhost:5050`. 62 | 63 | It's important to note that **for the best development experience, you should run the backend and frontend apps separately**. This way, you can take advantage of the hot-reload feature from Webpack and Django's development server. 64 | 65 | Although you can replicate the aforementioned behavior on a production environment (run the backend and frontend apps on differen servers), **this project is built to run both apps on the same server in production, with the frontend app being served by Django's template engine and view functions**. You can learn more about how everything is tied up together below 👇 66 | 67 | ## Application architecture 68 | 69 | This application's architect is quite simple and leverages the best of both Django and React. On a nutshell, React and Django integrate through Django's view functions and Django Rest Framework's API endpoints. There is no secret sauce here, just a simple and straightforward integration. 70 | 71 | ```mermaid 72 | flowchart TD 73 | ns("Frontend") --> ny("React") & n9("Env. variables") 74 | nl("Backend") --> nt("Django") & ni("Django Rest Framework") 75 | nt --> n5("Views") & nb("Templates") & na("Models") 76 | n5 --> nb 77 | ny --> n0("API Client") & n4("Root Container") 78 | na --> nn("API Key") & nd("Publications") 79 | n4 -- Mounts on same file from\nDjango templates --> nb 80 | n9 -.-> nn & n0 81 | ni -- Provides a REST\nendpoint to manipulate\ndata from models --> ng("REST API") 82 | ng --> nd 83 | n0 -- Consumes API Key\nto authenticate\nwith backend --> ng 84 | ``` 85 | 86 | ### Features 87 | 88 | Below you will find the stack used for each part of the application and the features that are already implemented. 89 | 90 | | Stack | Libraries and services | Features | 91 | | ---------- | ----------------------------------------------------------------- | -------------------------------- | 92 | | Frontend | React 18, React Router 6, Typescript 5, Webpack 5, Tailwind CSS 3 | Publication listing and search | 93 | | Backend | Django 5, Django Rest Framework | Publication CRUD, API Key CRUD | 94 | | Database | Postgres | - | 95 | | CDN | Cloudinary | - | 96 | | CI/CD | GitHub Actions | Multiple deploy workflow options | 97 | | Monitoring | Sentry | - | 98 | 99 | ## Going to production: infrastructure & deployment 100 | 101 | Although this project provides some guidelines on how to deploy the app, it is not mandatory to follow them. You can deploy the app on any platform you want, as long as it supports Docker and Docker Compose, or even deploy the app on a bare-metal machine. **By the end of the day, you should use the provided GitHub Actions workflows as a reference to build your own deployment pipeline and meet your requirements**. 102 | 103 | Nonetheless, this codebase has two deploy methods available via GitHub actions: 104 | 105 | ### Virtualized Deploy Workflow 106 | 107 | The `vm-deploy` branch will trigger this wokflow. You can use it to deploy the app to any Virtual Machine accessible via SSH (AWS EC2s, GCloud apps, Digital Ocean droplets, Hostgator VPSs, etc), and you would likely want to change the name of these branches to something more meaningful to your project. 108 | 109 | This is what the workflow does: 110 | 111 | ```mermaid 112 | flowchart LR 113 | nv("Make changes to\nthe application") -- Commit --> n7("Build and test frontend\nand backend") 114 | n7 --> nf{"Success?"} 115 | nf -- Yes --> n2("Build and test docker\nimage") 116 | nf -- No --> n4("Cancel pipeline") 117 | n2 --> n5{"Success?"} 118 | n5 -- No --> n4 119 | n5 -- Yes --> nx("Publish docker image\nto GitHub packages") 120 | nx --> nj{"Success?"} 121 | nj -- No --> n4 122 | nj -- Yes --> nd("Deploy") 123 | nd --> nc("Login into VM and copy\ndocker compose file\nfrom repo") 124 | nc --> n6{"Success?"} 125 | n6 -- Yes --> no("Pull previously pushed Docker image\nand execute most recent\n docker compose file") 126 | n6 -- No --> n4 127 | no --> na{"Success?"} 128 | na -- No --> ng("Better check your\nsystem's health") 129 | na -- Yes --> nk("Updates are live") 130 | ``` 131 | 132 | ### Bare-metal Deploy Workflow 133 | 134 | The `bare-metal-deploy` branches will trigger this workflow. You can use it to deploy the app straight on the host machine, without any virtualization. This is not recommended, but ou never know when you will need to deploy an app on a bare-metal machine 🤷‍♀️. This pipeline assumes that you've got Node.js, Python, [Gunicorn](https://gunicorn.org/) and [Supervisord](http://supervisord.org/) installed on the host machine. 135 | 136 | This is what the workflow does: 137 | 138 | ```mermaid 139 | flowchart LR 140 | nv("Make changes to\nthe application") -- Commit --> n7("Build and test frontend\nand backend") 141 | n7 --> nf{"Success?"} 142 | nf -- Yes --> n2("Logs into host machine\nand copy all relevant files\nfrom repo to it") 143 | nf -- No --> n4("Cancel pipeline") 144 | n2 --> n5{"Success?"} 145 | n5 -- No --> n4 146 | n5 -- Yes --> nx("Builds the frontend\nand the backend") 147 | nx --> nj{"Success?"} 148 | nj -- No --> n4 149 | nj -- Yes --> nd("Deploy") 150 | nd --> nc("Starts gunicorn server under\nsupervisor to ensure the\nsystem is never down") 151 | na{"Success?"} -- No --> ng("Better manually log in\ninto host and fix it") 152 | na -- Yes --> nk("Updates are live") 153 | nc --> na 154 | ``` 155 | 156 | ### Configuration 157 | 158 | You must be familiar with the expected environment variables to run the project. Here is a list of the environment variables you must set alongside the ones you already know ([`.env.example`](./.env.example) from root, [`.env.example`](./frontend/.env.example) from frontend) production environments and must be set as secrets on your GitHub repository and made available to GitHub Actions. 159 | 160 | | Environment variable | Description | 161 | | -------------------- | ------------------------------------------------------------------------------------- | 162 | | IMAGE_NAME | Docker image name | 163 | | MODE | `production`. This is hardcoded on the [Dockerfile](./Dockerfile) | 164 | | ALLOWED_HOSTS | A set of hosts allowed to pass CORS policy. I.g: "www.example.com" "example.com" | 165 | | DEPLOY_TOKEN | A Github token with permission to pull this project's image from your Github registry | 166 | | HOST | The domain under which your site will be hosted (i.g.:example.com) | 167 | | SSH_PRIVATE_KEY | The SSH key used to access the host machine | 168 | | USERNAME | The SSH username used to access the host machine | | 169 | 170 | ### Architecture overview 171 | 172 | Building up on the [application architecture diagram](#application-architecture), here is a more detailed overview of how the application is structured on a production environment: 173 | 174 | ```mermaid 175 | flowchart TD 176 | subgraph subgraph_byfiey99u["Gunicorn"] 177 | ny("React") 178 | n9("Env. variables") 179 | ns("Frontend") 180 | nt("Django") 181 | ni("Django Rest Framework") 182 | nl("Backend") 183 | n5("Views") 184 | nb("Templates") 185 | na("Models") 186 | n0("API Client") 187 | n4("Root Container") 188 | nn("API Key") 189 | nd("Publications") 190 | ng("REST API") 191 | react_assets("Static assets\n(React included)") 192 | end 193 | subgraph subgraph_3hmsyzvqm["Docker Image/Host"] 194 | subgraph_byfiey99u 195 | nginx("NGINX") 196 | pg("Postgres") 197 | end 198 | ns --> ny & n9 199 | nl --> nt & ni 200 | nt --> n5 & nb & na & subgraph_3hmsyzvqm 201 | nt -- Stores media\nfiles on --> nk("CDN\n(Cloudinary)") 202 | n5 --> nb 203 | ny --> n0 & n4 204 | na --> nn & nd 205 | n4 -- Mounts on same file from\nDjango templates --> nb 206 | n9 -.-> nn & n0 207 | ni -- Provides a REST\nendpoint to manipulate\ndata from models --> ng 208 | ng --> nd 209 | n0 -- Consumes API Key\nto authenticate\nwith backend --> ng 210 | nt -- Serves --> react_assets 211 | nginx -- Serves --> subgraph_byfiey99u 212 | nt -- Stores\ndata\non --> pg 213 | style subgraph_byfiey99u stroke:#000000 214 | style subgraph_3hmsyzvqm stroke:#000000 215 | ``` 216 | 217 | ## Similar projects 218 | 219 | React and Django are a great combination, and there are many projects out there that leverage the best of both worlds. Make sure to check them out if you're looking for a more opinionated boilerplate/different approach: 220 | 221 | - [django-react-boilerplate](https://github.com/vintasoftware/django-react-boilerplate) 222 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/api/__init__.py -------------------------------------------------------------------------------- /api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = "api" 6 | -------------------------------------------------------------------------------- /api/filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/api/filters/__init__.py -------------------------------------------------------------------------------- /api/filters/publications.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework as filters 2 | from backend.models.publications import Publication 3 | 4 | 5 | class PublicationFilter(filters.FilterSet): 6 | tag = filters.CharFilter(field_name="tag", lookup_expr="icontains") 7 | title = filters.CharFilter(field_name="title", lookup_expr="icontains") 8 | 9 | class Meta: 10 | model = Publication 11 | fields = ["title", "tag"] 12 | -------------------------------------------------------------------------------- /api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/api/migrations/__init__.py -------------------------------------------------------------------------------- /api/serializers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/api/serializers/__init__.py -------------------------------------------------------------------------------- /api/serializers/publications.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from backend.models.publications import Publication 3 | 4 | 5 | class PublicationsSerializer(serializers.ModelSerializer): 6 | image = serializers.SerializerMethodField(read_only=True) 7 | 8 | def get_image(self, instance): 9 | return instance.image.url 10 | 11 | class Meta: 12 | model = Publication 13 | fields = ( 14 | "title", 15 | "slug", 16 | "description", 17 | "body", 18 | "image", 19 | "tag", 20 | "image_description", 21 | "created_at", 22 | ) 23 | -------------------------------------------------------------------------------- /api/serializers/subscribers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from backend.models.subscribers import Subscriber 3 | 4 | 5 | class SubscribersSerializer(serializers.ModelSerializer): 6 | class Meta: 7 | model = Subscriber 8 | fields = ("name", "contact_method", "contact_info") 9 | -------------------------------------------------------------------------------- /api/tests.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/api/tests.py -------------------------------------------------------------------------------- /api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from rest_framework.authtoken.views import obtain_auth_token 3 | from .views.subscribers import SubscribersEndpoint 4 | from .views.publications import ( 5 | PublicationsEndpoint, 6 | PublicationsQueryEndpoint, 7 | PaginatedPublicationsQueryEndpoint, 8 | PaginatedPublicationsEndpoint, 9 | PublicationEndpoint, 10 | ) 11 | 12 | 13 | urlpatterns = [ 14 | re_path(r"^subscribers/$", SubscribersEndpoint.as_view()), 15 | re_path(r"^publications/p/$", PaginatedPublicationsEndpoint.as_view()), 16 | re_path(r"^publications/filter/$", PublicationsQueryEndpoint.as_view()), 17 | re_path(r"^publications/p/filter/$", PaginatedPublicationsQueryEndpoint.as_view()), 18 | re_path(r"^publications/(?P[\w\-]+)/$", PublicationEndpoint.as_view()), 19 | re_path(r"^publications/$", PublicationsEndpoint.as_view()), 20 | re_path(r"^authenticate/$", obtain_auth_token), 21 | ] 22 | -------------------------------------------------------------------------------- /api/utils.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import PageNumberPagination 2 | from rest_framework.response import Response 3 | from collections import OrderedDict 4 | 5 | 6 | class Pagination(PageNumberPagination): 7 | page_size = 9 8 | page_size_query_params = "page_size" 9 | max_page_size = 100 10 | 11 | def get_paginated_response(self, data): 12 | return Response( 13 | OrderedDict( 14 | [ 15 | ("count", self.page.paginator.count), 16 | ("current_page", self.page.number), 17 | ("total_pages", self.page.paginator.num_pages), 18 | ("next", self.get_next_link()), 19 | ("previous", self.get_previous_link()), 20 | ("results", data), 21 | ] 22 | ) 23 | ) 24 | -------------------------------------------------------------------------------- /api/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/api/views/__init__.py -------------------------------------------------------------------------------- /api/views/publications.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics 2 | from rest_framework.views import APIView 3 | from rest_framework.response import Response 4 | from django_filters.rest_framework import DjangoFilterBackend 5 | from backend.models.publications import Publication 6 | from api.filters.publications import PublicationFilter 7 | from api.serializers.publications import PublicationsSerializer 8 | from api.utils import Pagination 9 | from rest_framework.permissions import IsAuthenticated 10 | 11 | 12 | class PaginatedPublicationsEndpoint(generics.ListAPIView): 13 | permission_classes = (IsAuthenticated,) 14 | queryset = Publication.objects.all() 15 | serializer_class = PublicationsSerializer 16 | pagination_class = Pagination 17 | 18 | 19 | class PublicationsEndpoint(generics.ListAPIView): 20 | permission_classes = (IsAuthenticated,) 21 | queryset = Publication.objects.all() 22 | serializer_class = PublicationsSerializer 23 | 24 | 25 | class PublicationsQueryEndpoint(generics.ListAPIView): 26 | permission_classes = (IsAuthenticated,) 27 | queryset = Publication.objects.all() 28 | serializer_class = PublicationsSerializer 29 | filter_backends = [DjangoFilterBackend] 30 | filterset_class = PublicationFilter 31 | 32 | 33 | class PaginatedPublicationsQueryEndpoint(generics.ListAPIView): 34 | permission_classes = (IsAuthenticated,) 35 | queryset = Publication.objects.all() 36 | serializer_class = PublicationsSerializer 37 | filter_backends = [DjangoFilterBackend] 38 | filterset_class = PublicationFilter 39 | pagination_class = Pagination 40 | 41 | 42 | class PublicationEndpoint(APIView): 43 | permission_classes = (IsAuthenticated,) 44 | 45 | def get(self, request, format=None, **kwargs): 46 | """ 47 | Returns the a publication by its slug. 48 | """ 49 | 50 | try: 51 | publication = Publication.objects.get(slug=kwargs.get("slug")) 52 | 53 | formatted_publication = { 54 | "title": publication.title, 55 | "description": publication.description, 56 | "created_at": publication.created_at, 57 | "slug": publication.slug, 58 | "body": publication.body, 59 | "image": publication.image.url, 60 | } 61 | 62 | return Response(formatted_publication) 63 | except Publication.DoesNotExist: 64 | return Response("This publication doesn't exist yet.") 65 | -------------------------------------------------------------------------------- /api/views/subscribers.py: -------------------------------------------------------------------------------- 1 | from api.serializers.subscribers import SubscribersSerializer 2 | from rest_framework.views import APIView 3 | from rest_framework.response import Response 4 | from rest_framework import status 5 | from rest_framework.permissions import IsAuthenticated 6 | 7 | 8 | class SubscribersEndpoint(APIView): 9 | """ 10 | Interface for users to send their subscription data. 11 | """ 12 | 13 | permission_classes = (IsAuthenticated,) 14 | 15 | def post(self, request): 16 | serializer = SubscribersSerializer(data=request.data) 17 | 18 | if serializer.is_valid(): 19 | serializer.save() 20 | return Response(status=status.HTTP_201_CREATED) 21 | 22 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 23 | -------------------------------------------------------------------------------- /assets/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/assets/Logo.png -------------------------------------------------------------------------------- /backend/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "backend.apps.BackendConfig" 2 | -------------------------------------------------------------------------------- /backend/actions.py: -------------------------------------------------------------------------------- 1 | import csv 2 | from django.http import HttpResponse 3 | 4 | 5 | class ExportCsvMixin: 6 | def export_as_csv(self, _, queryset): 7 | meta = self.model._meta 8 | field_names = [field.name for field in meta.fields] 9 | 10 | response = HttpResponse(content_type="text/csv") 11 | response["Content-Disposition"] = "attachment; filename={}.csv".format(meta) 12 | writer = csv.writer(response) 13 | 14 | writer.writerow(field_names) 15 | for obj in queryset: 16 | writer.writerow([getattr(obj, field) for field in field_names]) 17 | 18 | return response 19 | 20 | export_as_csv.short_description = "Export selected to CSV" 21 | -------------------------------------------------------------------------------- /backend/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from .publications import PublicationAdmin 2 | from .subscribers import SubscriberAdmin -------------------------------------------------------------------------------- /backend/admin/publications.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.html import format_html 3 | from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin 4 | from backend.models.publications import Publication 5 | from backend.utils import Strings 6 | 7 | 8 | @admin.register(Publication) 9 | class PublicationAdmin(admin.ModelAdmin, DynamicArrayMixin): 10 | def image_preview(self, obj): 11 | return format_html( 12 | ''.format(obj.image.url) 13 | ) 14 | 15 | image_preview.short_description = Strings.IMAGE 16 | 17 | list_filter = ("created_at",) 18 | list_display = ("title", "image_preview", "created_at") 19 | readonly_fields = ["slug"] 20 | 21 | fieldsets = ( 22 | (None, {"fields": ("title", "slug", "body", "image", "tag")}), 23 | ( 24 | Strings.OPTIONAL_FIELDS, 25 | { 26 | "classes": ("collapse",), 27 | "fields": ("description", "image_description"), 28 | }, 29 | ), 30 | ) 31 | -------------------------------------------------------------------------------- /backend/admin/subscribers.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from backend.models.subscribers import Subscriber 3 | from backend.actions import ExportCsvMixin 4 | 5 | 6 | @admin.register(Subscriber) 7 | class SubscriberAdmin(admin.ModelAdmin, ExportCsvMixin): 8 | list_filter = ("contact_method", "created_at") 9 | actions = ["export_as_csv"] 10 | search_fields = ["name"] 11 | list_display = ("name", "contact_info", "created_at") 12 | readonly_fields = ["name", "contact_info", "contact_method"] 13 | 14 | class Media: 15 | js = ("backend/subscribers.js",) 16 | -------------------------------------------------------------------------------- /backend/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BackendConfig(AppConfig): 5 | name = "backend" 6 | verbose_name = "Public website" 7 | 8 | def ready(self): 9 | return 10 | -------------------------------------------------------------------------------- /backend/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/backend/management/__init__.py -------------------------------------------------------------------------------- /backend/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/backend/management/commands/__init__.py -------------------------------------------------------------------------------- /backend/management/commands/clearcache.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.core.cache import cache 3 | 4 | 5 | class Command(BaseCommand): 6 | def handle(self, *args, **kwargs): 7 | cache.clear() 8 | self.stdout.write("Cleared cache\n") 9 | -------------------------------------------------------------------------------- /backend/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-14 20:03 2 | 3 | import cloudinary.models 4 | from django.db import migrations, models 5 | import django_better_admin_arrayfield.models.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Publication", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("title", models.CharField(max_length=120, verbose_name="Title")), 27 | ( 28 | "body", 29 | models.TextField( 30 | default="", max_length=30000, verbose_name="Text body" 31 | ), 32 | ), 33 | ( 34 | "description", 35 | models.TextField( 36 | blank=True, 37 | help_text="Used to promote this page's content through search engines", 38 | max_length=3000, 39 | verbose_name="Description", 40 | ), 41 | ), 42 | ( 43 | "created_at", 44 | models.DateTimeField(auto_now_add=True, verbose_name="Created at"), 45 | ), 46 | ( 47 | "image", 48 | cloudinary.models.CloudinaryField( 49 | max_length=255, null=True, verbose_name="Image" 50 | ), 51 | ), 52 | ( 53 | "image_description", 54 | models.TextField( 55 | blank=True, 56 | help_text="Used to help screen readers describe this image to users with compromised vision.", 57 | max_length=500, 58 | verbose_name="Image description", 59 | ), 60 | ), 61 | ( 62 | "tag", 63 | django_better_admin_arrayfield.models.fields.ArrayField( 64 | base_field=models.CharField(max_length=200), 65 | null=True, 66 | size=None, 67 | verbose_name="Keyword", 68 | ), 69 | ), 70 | ("slug", models.SlugField(max_length=250, null=True, unique=True)), 71 | ], 72 | options={ 73 | "verbose_name": "Publication", 74 | "ordering": ["-created_at"], 75 | }, 76 | ), 77 | migrations.CreateModel( 78 | name="Subscriber", 79 | fields=[ 80 | ( 81 | "id", 82 | models.AutoField( 83 | auto_created=True, 84 | primary_key=True, 85 | serialize=False, 86 | verbose_name="ID", 87 | ), 88 | ), 89 | ("name", models.CharField(max_length=100, verbose_name="Name")), 90 | ( 91 | "contact_method", 92 | models.CharField( 93 | choices=[("EMAIL", "E-mail"), ("WHATSAPP", "Whatsapp")], 94 | default="EMAIL", 95 | max_length=300, 96 | verbose_name="Contact method", 97 | ), 98 | ), 99 | ( 100 | "contact_info", 101 | models.CharField(max_length=300, verbose_name="Contact"), 102 | ), 103 | ( 104 | "created_at", 105 | models.DateTimeField(auto_now_add=True, verbose_name="Created at"), 106 | ), 107 | ], 108 | options={ 109 | "verbose_name": "Subscriber", 110 | }, 111 | ), 112 | ] 113 | -------------------------------------------------------------------------------- /backend/migrations/0002_create_default_superuser.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | 4 | class Migration(migrations.Migration): 5 | dependencies = [ 6 | ("backend", "0001_initial"), 7 | ] 8 | 9 | def generate_superuser(apps, _): 10 | from django.contrib.auth.models import User 11 | 12 | SU_NAME = "admin" 13 | 14 | try: 15 | User.objects.get(username=SU_NAME) 16 | except User.DoesNotExist: 17 | SU_EMAIL = "admin@example.com" 18 | SU_PASSWORD = "admin" 19 | superuser = User.objects.create_superuser( 20 | username=SU_NAME, email=SU_EMAIL, password=SU_PASSWORD 21 | ) 22 | superuser.is_superuser = True 23 | superuser.is_staff = True 24 | superuser.save() 25 | 26 | operations = [ 27 | migrations.RunPython(generate_superuser), 28 | ] 29 | -------------------------------------------------------------------------------- /backend/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/backend/migrations/__init__.py -------------------------------------------------------------------------------- /backend/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/backend/models/__init__.py -------------------------------------------------------------------------------- /backend/models/base.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from backend.utils import Strings 3 | 4 | 5 | class ButtonBlock(models.Model): 6 | button_label = models.CharField(max_length=120, verbose_name=Strings.BUTTON_LABEL) 7 | button_url = models.CharField( 8 | max_length=120, verbose_name=Strings.BUTTON_LINK, blank=True, null=True 9 | ) 10 | 11 | class Meta: 12 | abstract = True 13 | 14 | 15 | class TextBlockBase(models.Model): 16 | title = models.CharField(max_length=120, verbose_name=Strings.TITLE) 17 | body = models.TextField(max_length=30000, verbose_name=Strings.BODY, default="") 18 | description = models.TextField( 19 | max_length=3000, 20 | verbose_name=Strings.DESCRIPTION, 21 | help_text=Strings.DESCRIPTION_HELPER, 22 | blank=True, 23 | ) 24 | created_at = models.DateTimeField( 25 | auto_now_add=True, verbose_name=Strings.CREATED_AT 26 | ) 27 | 28 | def __str__(self): 29 | return self.title 30 | 31 | class Meta: 32 | abstract = True 33 | 34 | 35 | class TextBlock(TextBlockBase): 36 | class Meta: 37 | abstract = True 38 | 39 | 40 | class EnhancedTextBlock(TextBlockBase, ButtonBlock): 41 | subtitle = models.CharField(max_length=120, verbose_name=Strings.SUBTITLE) 42 | 43 | class Meta: 44 | abstract = True 45 | -------------------------------------------------------------------------------- /backend/models/publications.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django_better_admin_arrayfield.models.fields import ArrayField 3 | from .base import TextBlock 4 | from cloudinary.models import CloudinaryField 5 | from backend.utils import Strings, compress_image 6 | from django.core.files import File 7 | from django.core.files.temp import NamedTemporaryFile 8 | from urllib.request import urlopen 9 | 10 | 11 | class Publication(TextBlock): 12 | image = CloudinaryField(Strings.IMAGE, null=True) 13 | image_description = models.TextField( 14 | max_length=500, 15 | verbose_name=Strings.IMAGE_DESCRIPTION, 16 | help_text=Strings.IMAGE_DESCRIPTION_HELPER, 17 | blank=True, 18 | ) 19 | tag = ArrayField( 20 | models.CharField(max_length=200), null=True, verbose_name=Strings.KEYWORD 21 | ) 22 | slug = models.SlugField(max_length=250, unique=True, null=True) 23 | 24 | def save(self, *args, **kwargs): 25 | try: 26 | self.image = compress_image(self.image) 27 | except AttributeError: 28 | img_temp = NamedTemporaryFile(delete=True) 29 | img_temp.write(urlopen(self.image.url).read()) 30 | img_temp.flush() 31 | self.image = compress_image(File(img_temp)) 32 | 33 | super(Publication, self).save(*args, **kwargs) 34 | 35 | def __str__(self): 36 | return self.title 37 | 38 | class Meta: 39 | verbose_name = Strings.PUBLICATION 40 | ordering = ["-created_at"] 41 | -------------------------------------------------------------------------------- /backend/models/subscribers.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from backend.utils import Strings 3 | 4 | 5 | class Subscriber(models.Model): 6 | class ContactMethod(models.TextChoices): 7 | EMAIL = "EMAIL", "E-mail" 8 | WHATSAPP = "WHATSAPP", "Whatsapp" 9 | 10 | name = models.CharField(max_length=100, verbose_name=Strings.NAME) 11 | contact_method = models.CharField( 12 | max_length=300, 13 | choices=ContactMethod.choices, 14 | default=ContactMethod.EMAIL, 15 | verbose_name=Strings.CONTACT_METHOD, 16 | ) 17 | contact_info = models.CharField(max_length=300, verbose_name=Strings.CONTACT) 18 | created_at = models.DateTimeField( 19 | auto_now_add=True, verbose_name=Strings.CREATED_AT 20 | ) 21 | 22 | def __str__(self): 23 | return self.name 24 | 25 | class Meta: 26 | verbose_name = Strings.SUBSCRIBER 27 | -------------------------------------------------------------------------------- /backend/signals.py: -------------------------------------------------------------------------------- 1 | from .models.publications import Publication 2 | from .models.subscribers import Subscriber 3 | from .utils import unique_slug_generator, smart_truncate 4 | from django.db.models.signals import pre_save, post_save 5 | from django.dispatch import receiver 6 | from django.core.mail import EmailMultiAlternatives 7 | from django.template.loader import render_to_string 8 | from core.settings.base import ( 9 | EMAIL_HOST_USER, 10 | ) 11 | 12 | 13 | @receiver(pre_save, sender=Publication) 14 | def populate_slug_field(sender, instance, **kwargs): 15 | if not instance.slug: 16 | instance.slug = unique_slug_generator(instance) 17 | 18 | 19 | @receiver(post_save, sender=Publication) 20 | def send_newsletter(sender, instance, created, **kwargs): 21 | if created: 22 | subscribers_emails = list( 23 | Subscriber.objects.filter(contact_method="EMAIL").values_list( 24 | "contact_info", flat=True 25 | ) 26 | ) 27 | 28 | html_content = render_to_string( 29 | "backend/email.html", 30 | { 31 | "title": instance.title, 32 | "description": instance.description, 33 | "body": smart_truncate(instance.body), 34 | "slug": instance.slug, 35 | }, 36 | ) 37 | 38 | sent = [] 39 | 40 | for subscriber_email in subscribers_emails: 41 | if subscriber_email not in sent: 42 | sent.append(subscriber_email) 43 | email = EmailMultiAlternatives( 44 | "News from Django-React-Typescript: {}".format(instance.title), 45 | None, 46 | EMAIL_HOST_USER, 47 | [subscriber_email], 48 | ) 49 | email.attach_alternative(html_content, "text/html") 50 | email.send() 51 | -------------------------------------------------------------------------------- /backend/static/backend/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/backend/static/backend/logo.png -------------------------------------------------------------------------------- /backend/static/backend/subscribers.js: -------------------------------------------------------------------------------- 1 | window.onload = function () { 2 | document.getElementById("searchbar").placeholder = 3 | "Search for name or neighborhood"; 4 | }; 5 | -------------------------------------------------------------------------------- /backend/templates/backend/email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Django-React-Typescript news 7 | 336 | 337 | 338 | {{ title }} 339 | 346 | 347 | 348 | 428 | 429 | 430 | 431 | 432 | 433 | -------------------------------------------------------------------------------- /backend/tests.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/backend/tests.py -------------------------------------------------------------------------------- /backend/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.utils.text import slugify 3 | from PIL import Image 4 | from io import BytesIO 5 | from django.core.files.uploadedfile import InMemoryUploadedFile 6 | 7 | 8 | class Strings: 9 | BUTTON_LABEL = "Button's label" 10 | BUTTON_LINK = "Button's link" 11 | IMAGE = "Image" 12 | IMAGE_DESCRIPTION = "Image description" 13 | IMAGE_DESCRIPTION_HELPER = "Used to help screen readers describe this image to users with compromised vision." 14 | TITLE = "Title" 15 | BIOGRAPHY = "Biography" 16 | SUBTITLE = "Subtitle" 17 | DESCRIPTION = "Description" 18 | DESCRIPTION_HELPER = "Used to promote this page's content through search engines" 19 | PREVIEW = "" 20 | BODY = "Text body" 21 | CREATED_AT = "Created at" 22 | NAME = "Name" 23 | EMAIL = "Email" 24 | MESSAGE = "Message" 25 | CONTACT = "Contact" 26 | CONTACT_METHOD = "Contact method" 27 | AGE = "Age" 28 | CELLPHONE = "Cellphone" 29 | KEYWORD = "Keyword" 30 | PUBLICATION = "Publication" 31 | ADVANCED_OPTIONS = "Advanced options" 32 | OPTIONAL_FIELDS = "Optional fields" 33 | SUBSCRIBER = "Subscriber" 34 | 35 | 36 | def unique_slug_generator(model_instance): 37 | slug = slugify(model_instance.title) 38 | model_class = model_instance.__class__ 39 | 40 | while model_class._default_manager.filter(slug=slug).exists(): 41 | object_pk = model_class._default_manager.latest("pk") 42 | object_pk = object_pk.pk + 1 43 | slug = f"{slug}-{object_pk}" 44 | 45 | return slug 46 | 47 | 48 | def compress_image(image): 49 | img = Image.open(image) 50 | if img.mode != "RGB": 51 | img = img.convert("RGB") 52 | io_stream = BytesIO() 53 | img.save(io_stream, format="JPEG", quality=60) 54 | io_stream.seek(0) 55 | 56 | return InMemoryUploadedFile( 57 | io_stream, 58 | "CloudinaryField", 59 | "%s.jpg" % image.name.split(".")[0], 60 | "image/jpeg", 61 | sys.getsizeof(io_stream), 62 | None, 63 | ) 64 | 65 | 66 | def smart_truncate(content, length=300, suffix="..."): 67 | if len(content) <= length: 68 | return content 69 | else: 70 | return " ".join(content[: length + 1].split(" ")[0:-1]) + suffix 71 | -------------------------------------------------------------------------------- /config/certs/certbot.sh: -------------------------------------------------------------------------------- 1 | sudo add-apt-repository ppa:certbot/certbot 2 | sudo apt install python-certbot-nginx 3 | sudo certbot --nginx -d example.com -d www.example.com -d hml.example.com 4 | -------------------------------------------------------------------------------- /config/nginx/www.example.com: -------------------------------------------------------------------------------- 1 | server { 2 | 3 | server_name example.com www.example.com; 4 | 5 | client_max_body_size 2M; 6 | 7 | gzip on; 8 | gzip_types text/plain application/json application/xml; 9 | gzip_proxied no-cache no-store private expired auth; 10 | gzip_min_length 1000; 11 | 12 | location / { 13 | proxy_set_header Host $host; 14 | proxy_set_header X-Real-Ip $remote_addr; 15 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 16 | proxy_pass http://127.0.0.1:8000; 17 | } 18 | } -------------------------------------------------------------------------------- /config/supervisor/drt.ini: -------------------------------------------------------------------------------- 1 | [program:drt] 2 | command=/home/your-username/.local/bin/gunicorn es.wsgi -b 0.0.0.0:8000 3 | directory=/home/your-username/app/ 4 | user=your-username 5 | autorestart=true 6 | stdout_logfile = /home/your-username/gunicorn_supervisor.log 7 | redirect_stderr=true 8 | environment=LANG=en_US.UTF-8,LC_ALL=en_US.UTF-8 -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/core/__init__.py -------------------------------------------------------------------------------- /core/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for es project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.base") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /core/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/core/settings/__init__.py -------------------------------------------------------------------------------- /core/settings/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for es project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.0.7. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | from cloudinary import config 15 | 16 | 17 | PRODUCTION_MODE = os.getenv("MODE") == "production" 18 | 19 | ALLOWED_HOSTS = ["*"] 20 | 21 | # Django Rest Framework 22 | # https://www.django-rest-framework.org/ 23 | 24 | REST_FRAMEWORK = { 25 | "DEFAULT_AUTHENTICATION_CLASSES": ( 26 | "rest_framework.authentication.TokenAuthentication", 27 | ), 28 | } 29 | 30 | if PRODUCTION_MODE: 31 | from .prod import * 32 | else: 33 | from dotenv import load_dotenv 34 | 35 | load_dotenv() 36 | from .dev import * 37 | 38 | CDN_NAME = os.environ.get("CDN_NAME") 39 | CDN_API_KEY = os.environ.get("CDN_API_KEY") 40 | CDN_API_SECRET = os.environ.get("CDN_API_SECRET") 41 | 42 | TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID") 43 | TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN") 44 | TWILIO_WPP_NUMBER = os.environ.get("TWILIO_WPP_NUMBER") 45 | 46 | DB_HOST = os.environ.get("DB_HOST") 47 | DB_NAME = os.environ.get("DB_NAME") 48 | DB_USER = os.environ.get("DB_USER") 49 | DB_PASSWORD = os.environ.get("DB_PASSWORD") 50 | DB_PORT = os.environ.get("DB_PORT") 51 | 52 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 53 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 54 | 55 | 56 | # Quick-start development settings - unsuitable for production 57 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 58 | 59 | # SECURITY WARNING: keep the secret key used in production secret! 60 | SECRET_KEY = os.environ.get( 61 | "SECRET_KEY", default="w%h-ok)&7l2e@1&ht!#ol3!!qg9zwz9hs$wf@fk4e0-7x1r*#d" 62 | ) 63 | 64 | # Application definition 65 | 66 | INSTALLED_APPS = [ 67 | "django.contrib.admin", 68 | "django.contrib.auth", 69 | "django.contrib.contenttypes", 70 | "django.contrib.sessions", 71 | "django.contrib.messages", 72 | "django.contrib.staticfiles", 73 | "django.contrib.sites", 74 | "corsheaders", 75 | "rest_framework", 76 | "rest_framework.authtoken", 77 | "django_better_admin_arrayfield", 78 | "django_filters", 79 | "frontend", 80 | "backend", 81 | "api", 82 | "cloudinary", 83 | ] 84 | 85 | SITE_ID = 1 86 | 87 | MIDDLEWARE = [ 88 | "django.middleware.security.SecurityMiddleware", 89 | "django.contrib.sessions.middleware.SessionMiddleware", 90 | "corsheaders.middleware.CorsMiddleware", 91 | "django.middleware.common.CommonMiddleware", 92 | "django.middleware.csrf.CsrfViewMiddleware", 93 | "django.contrib.auth.middleware.AuthenticationMiddleware", 94 | "django.contrib.messages.middleware.MessageMiddleware", 95 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 96 | ] 97 | 98 | ROOT_URLCONF = "core.urls" 99 | 100 | TEMPLATES = [ 101 | { 102 | "BACKEND": "django.template.backends.django.DjangoTemplates", 103 | "DIRS": [], 104 | "APP_DIRS": True, 105 | "OPTIONS": { 106 | "context_processors": [ 107 | "django.template.context_processors.debug", 108 | "django.template.context_processors.request", 109 | "django.contrib.auth.context_processors.auth", 110 | "django.contrib.messages.context_processors.messages", 111 | ], 112 | }, 113 | }, 114 | ] 115 | 116 | WSGI_APPLICATION = "core.wsgi.application" 117 | 118 | 119 | # Database 120 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 121 | 122 | if os.getenv("TEST"): 123 | DATABASES = { 124 | "default": { 125 | "ENGINE": "django.db.backends.sqlite3", 126 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 127 | } 128 | } 129 | else: 130 | DATABASES = { 131 | "default": { 132 | "ENGINE": "django.db.backends.postgresql", 133 | "NAME": DB_NAME, 134 | "USER": DB_USER, 135 | "PASSWORD": DB_PASSWORD, 136 | "HOST": DB_HOST, 137 | "PORT": DB_PORT, 138 | } 139 | } 140 | 141 | 142 | # Password validation 143 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 144 | 145 | AUTH_PASSWORD_VALIDATORS = [ 146 | { 147 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 148 | }, 149 | { 150 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 151 | }, 152 | { 153 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 154 | }, 155 | { 156 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 157 | }, 158 | ] 159 | 160 | 161 | # Internationalization 162 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 163 | 164 | LANGUAGE_CODE = "en" 165 | 166 | TIME_ZONE = "America/New_York" 167 | 168 | USE_I18N = True 169 | 170 | USE_L10N = True 171 | 172 | USE_TZ = True 173 | 174 | 175 | # Static files (CSS, JavaScript, Images) 176 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 177 | 178 | STATIC_URL = "/static/" 179 | STATIC_ROOT = os.path.join(BASE_DIR, "static/") 180 | 181 | MEDIA_URL = "/media/" 182 | MEDIA_ROOT = os.path.join(BASE_DIR, "media/") 183 | 184 | config(cloud_name=CDN_NAME, api_key=CDN_API_KEY, api_secret=CDN_API_SECRET, secure=True) 185 | 186 | # Email 187 | # https://docs.djangoproject.com/en/3.0/topics/email/ 188 | 189 | # Gmail SMTP requirements 190 | # https://support.google.com/a/answer/176600?hl=en 191 | 192 | EMAIL_BACKEND = ( 193 | "django.core.mail.backends.smtp.EmailBackend" 194 | if PRODUCTION_MODE 195 | else "django.core.mail.backends.dummy.EmailBackend" 196 | ) 197 | EMAIL_HOST = "smtp.gmail.com" 198 | EMAIL_HOST_USER = os.environ.get("SMTP_HOST_USER") 199 | EMAIL_HOST_PASSWORD = os.environ.get("SMTP_HOST_PASSWORD") 200 | EMAIL_PORT = 587 201 | EMAIL_USE_TLS = True 202 | EMAIL_USE_SSL = False 203 | -------------------------------------------------------------------------------- /core/settings/dev.py: -------------------------------------------------------------------------------- 1 | # SECURITY WARNING: don't run with debug turned on in production! 2 | DEBUG = True 3 | 4 | CORS_ORIGIN_WHITELIST = ["http://0.0.0.0:4000", "http://localhost:4000"] 5 | -------------------------------------------------------------------------------- /core/settings/prod.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | AH = os.environ.get("ALLOWED_HOSTS") 4 | 5 | if AH: 6 | ALLOWED_HOSTS = AH.split(" ") 7 | 8 | DEBUG = False 9 | 10 | CSRF_COOKIE_SECURE = True 11 | SESSION_COOKIE_SECURE = True 12 | USE_X_FORWARDED_PORT = True 13 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 14 | 15 | 16 | # Sentry 17 | 18 | import sentry_sdk 19 | from sentry_sdk.integrations.django import DjangoIntegration 20 | 21 | SENTRY_DNS = os.environ.get("SENTRY_DNS") 22 | 23 | sentry_sdk.init( 24 | dsn=SENTRY_DNS, 25 | integrations=[DjangoIntegration()], 26 | # If you wish to associate users to errors (assuming you are using 27 | # django.contrib.auth) you may enable sending PII data. 28 | send_default_pii=True, 29 | ) 30 | 31 | # Django CORS Headers 32 | 33 | CORS_ORIGIN_WHITELIST = [ 34 | "https://example.com", 35 | "https://www.example.com", 36 | ] 37 | 38 | # DRF 39 | 40 | REST_FRAMEWORK = { 41 | "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), 42 | "DEFAULT_AUTHENTICATION_CLASSES": ( 43 | "rest_framework.authentication.TokenAuthentication", 44 | ), 45 | } 46 | 47 | # Memcached and pylibmc 48 | 49 | CACHES = { 50 | "default": { 51 | "BACKEND": "django.core.cache.backends.memcached.MemcachedCache", 52 | "LOCATION": "127.0.0.1:11211", 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /core/urls.py: -------------------------------------------------------------------------------- 1 | """es URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | 17 | from django.contrib import admin 18 | from django.urls import re_path, include 19 | from core.settings.base import STATIC_ROOT, MEDIA_ROOT 20 | from django.views.static import serve 21 | 22 | admin.site.site_header = "Django-React-Typescript Admin" 23 | admin.site.site_title = "Django-React-Typescript Admin" 24 | admin.site.index_title = "Modules" 25 | 26 | urlpatterns = [ 27 | re_path(r"^admin/", admin.site.urls), 28 | re_path(r"^api/", include("api.urls")), 29 | re_path( 30 | r"^static/(?P.*)$", 31 | serve, 32 | { 33 | "document_root": STATIC_ROOT, 34 | }, 35 | ), 36 | re_path( 37 | r"^media/(?P.*)$", 38 | serve, 39 | { 40 | "document_root": MEDIA_ROOT, 41 | }, 42 | ), 43 | re_path(r"^", include("frontend.urls")), 44 | ] 45 | -------------------------------------------------------------------------------- /core/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for es project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.base") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docker-compose-dev-db.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | postgres: 4 | container_name: container-pg 5 | image: postgres 6 | hostname: localhost 7 | ports: 8 | - "5432:5432" 9 | environment: 10 | POSTGRES_USER: admin 11 | POSTGRES_PASSWORD: root 12 | POSTGRES_DB: dev_db 13 | volumes: 14 | - postgres-data:/var/lib/postgresql/data 15 | restart: unless-stopped 16 | 17 | pgadmin: 18 | container_name: container-pgadmin 19 | image: dpage/pgadmin4 20 | depends_on: 21 | - postgres 22 | ports: 23 | - "5050:80" 24 | environment: 25 | PGADMIN_DEFAULT_EMAIL: admin@admin.com 26 | PGADMIN_DEFAULT_PASSWORD: root 27 | restart: unless-stopped 28 | 29 | volumes: 30 | postgres-data: 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | web: 5 | restart: always 6 | image: docker.pkg.github.com/marcelovicentegc/django-react-typescript/django-react-typescript:latest 7 | expose: 8 | - "8000" 9 | ports: 10 | - "8000:8000" 11 | links: 12 | - postgres:postgres 13 | - memcached:memcached 14 | environment: 15 | SECRET_KEY: ${SECRET_KEY} 16 | DB_HOST: ${DB_HOST} 17 | DB_NAME: ${DB_NAME} 18 | DB_USER: ${DB_USER} 19 | DB_PORT: ${DB_PORT} 20 | DB_PASSWORD: ${DB_PASSWORD} 21 | CDN_NAME: ${CDN_NAME} 22 | CDN_API_KEY: ${CDN_API_KEY} 23 | CDN_API_SECRET: ${CDN_API_SECRET} 24 | SMTP_HOST_USER: ${SMTP_HOST_USER} 25 | SMTP_HOST_PASSWORD: ${SMTP_HOST_PASSWORD} 26 | depends_on: 27 | - postgres 28 | - memcached 29 | command: sh -c "poetry run python manage.py migrate && python manage.py collectstatic --no-input && python manage.py clearcache && gunicorn core.wsgi -b 0.0.0.0:8000" 30 | 31 | postgres: 32 | restart: always 33 | image: postgres:latest 34 | environment: 35 | POSTGRES_USER: ${DB_USER} 36 | POSTGRES_DB: ${DB_NAME} 37 | POSTGRES_PASSWORD: ${DB_PASSWORD} 38 | ports: 39 | - "5432:5432" 40 | volumes: 41 | - pgdata:/var/lib/postgresql/data/ 42 | 43 | memcached: 44 | restart: always 45 | image: memcached:latest 46 | ports: 47 | - "11211:11211" 48 | 49 | volumes: 50 | pgdata: 51 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | AUTH_TOKEN= 3 | -------------------------------------------------------------------------------- /frontend/.unimportedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": [ 3 | "**/node_modules/**", 4 | "**/*.tests.{js,jsx,ts,tsx}", 5 | "**/*.test.{js,jsx,ts,tsx}", 6 | "**/*.spec.{js,jsx,ts,tsx}", 7 | "**/tests/**", 8 | "**/__tests__/**", 9 | "**/*.d.ts" 10 | ], 11 | "ignoreUnimported": [], 12 | "ignoreUnused": [], 13 | "ignoreUnresolved": [], 14 | "respectGitignore": true 15 | } -------------------------------------------------------------------------------- /frontend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/frontend/__init__.py -------------------------------------------------------------------------------- /frontend/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FrontendConfig(AppConfig): 5 | name = "frontend" 6 | -------------------------------------------------------------------------------- /frontend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/preset-typescript", 4 | "@babel/preset-env", 5 | "@babel/preset-react", 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png" { 2 | const value: string; 3 | export default value; 4 | } 5 | 6 | declare module "*.woff2" { 7 | const path: string; 8 | export default path; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Root } from "./lib"; 4 | 5 | const container = document.getElementById("root"); 6 | 7 | if (!container) { 8 | throw new Error("Container not found"); 9 | } 10 | 11 | const root = createRoot(container); 12 | root.render(); 13 | -------------------------------------------------------------------------------- /frontend/lib/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./types"; 2 | export * from "./utils"; 3 | export * from "./use-api"; 4 | -------------------------------------------------------------------------------- /frontend/lib/api/types.ts: -------------------------------------------------------------------------------- 1 | interface TextBlock { 2 | title: string; 3 | body: string; 4 | description?: string; 5 | } 6 | 7 | export interface Publication extends TextBlock { 8 | slug: string; 9 | created_at: string; 10 | tag: string[]; 11 | image: string; 12 | image_description: string; 13 | } 14 | 15 | export interface GetPaginatedPublicationsResponse { 16 | count: number; 17 | current_page: number; 18 | total_pages: number; 19 | next: string | null; 20 | previous: string | null; 21 | results: Publication[]; 22 | } 23 | -------------------------------------------------------------------------------- /frontend/lib/api/use-api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getPaginatedPublicationsEndpoint, 3 | getPublicationsEndpoint, 4 | getPublicationEndpoint, 5 | getFilteredPublicationsEndoint, 6 | getPaginatedFilteredPublicationsEndoint, 7 | } from "./utils"; 8 | import { getSecrets } from "../config"; 9 | import type { GetPaginatedPublicationsResponse, Publication } from "./types"; 10 | 11 | const { isProd, authToken } = getSecrets(); 12 | 13 | const LOCAL_API_URL = "http://localhost:8000"; 14 | 15 | export function useApi() { 16 | const getHeaders = new Headers({ 17 | Accept: "*/*", 18 | "Accept-Encoding": "gzip, deflate, br", 19 | Authorization: "Token " + authToken, 20 | }); 21 | 22 | async function getPublications( 23 | args: { 24 | title: string; 25 | tag: string[]; 26 | } = { 27 | title: "", 28 | tag: [], 29 | } 30 | ): Promise { 31 | const endpoint = (() => { 32 | const shouldFilter = args.tag && args.title; 33 | 34 | if (isProd) { 35 | if (shouldFilter) { 36 | return getFilteredPublicationsEndoint(args); 37 | } 38 | 39 | return getPublicationsEndpoint; 40 | } 41 | 42 | if (shouldFilter) { 43 | return LOCAL_API_URL + getFilteredPublicationsEndoint(args); 44 | } 45 | 46 | return LOCAL_API_URL + getPublicationsEndpoint; 47 | })(); 48 | 49 | return fetch(endpoint, { 50 | cache: "default", 51 | method: "GET", 52 | headers: getHeaders, 53 | }) 54 | .then((response) => response.json()) 55 | .catch((error) => { 56 | console.error(error); 57 | return []; 58 | }); 59 | } 60 | 61 | async function getPaginatedPublications(args?: { 62 | page?: number; 63 | querystring?: string; 64 | filter?: { 65 | title?: string; 66 | tags?: string[]; 67 | }; 68 | }): Promise { 69 | const endpoint = (() => { 70 | if (!args) { 71 | if (isProd) { 72 | return getPaginatedPublicationsEndpoint; 73 | } 74 | 75 | return LOCAL_API_URL + getPaginatedPublicationsEndpoint; 76 | } 77 | 78 | if (args.querystring) { 79 | return args.querystring; 80 | } 81 | 82 | if (args.page && !args.filter) { 83 | if (isProd) { 84 | return getPaginatedPublicationsEndpoint + `?page=${args.page}`; 85 | } 86 | 87 | return ( 88 | LOCAL_API_URL + 89 | getPaginatedPublicationsEndpoint + 90 | `?page=${args.page}` 91 | ); 92 | } else if (args.filter) { 93 | if (isProd) { 94 | return getPaginatedFilteredPublicationsEndoint({ 95 | title: args.filter.title, 96 | tag: args.filter.tags, 97 | }); 98 | } 99 | 100 | return ( 101 | LOCAL_API_URL + 102 | getPaginatedFilteredPublicationsEndoint({ 103 | title: args.filter.title, 104 | tag: args.filter.tags, 105 | }) 106 | ); 107 | } 108 | })(); 109 | 110 | return fetch(endpoint, { 111 | cache: "default", 112 | method: "GET", 113 | headers: getHeaders, 114 | }) 115 | .then((response) => response.json()) 116 | .catch((error) => { 117 | console.error(error); 118 | return []; 119 | }); 120 | } 121 | 122 | async function getPublication(slug: string): Promise { 123 | return fetch( 124 | isProd 125 | ? getPublicationEndpoint(slug) 126 | : LOCAL_API_URL + getPublicationEndpoint(slug), 127 | { 128 | cache: "default", 129 | method: "GET", 130 | headers: getHeaders, 131 | } 132 | ) 133 | .then((response) => response.json()) 134 | .catch((error) => { 135 | console.error(error); 136 | return []; 137 | }); 138 | } 139 | 140 | return { 141 | getPublications, 142 | getPaginatedPublications, 143 | getPublication, 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /frontend/lib/api/utils.ts: -------------------------------------------------------------------------------- 1 | export function getFilteredPublicationsEndoint( 2 | args: { 3 | title: string; 4 | tag: string[]; 5 | } = { 6 | title: "", 7 | tag: [], 8 | } 9 | ) { 10 | const { title, tag } = args; 11 | 12 | return `/api/publications/filter/?title=${title}&tag=${tag}`; 13 | } 14 | 15 | export function getPaginatedFilteredPublicationsEndoint( 16 | args: { 17 | title: string; 18 | tag: string[]; 19 | } = { 20 | title: "", 21 | tag: [], 22 | } 23 | ) { 24 | const { title, tag } = args; 25 | 26 | return `/api/publications/p/filter/?title=${title}&tag=${tag ? tag : ""}`; 27 | } 28 | 29 | export function getPublicationEndpoint(slug: string) { 30 | return getPublicationsEndpoint + slug + "/"; 31 | } 32 | 33 | export const getPublicationsEndpoint = "/api/publications/"; 34 | export const getPaginatedPublicationsEndpoint = getPublicationsEndpoint + "p/"; 35 | -------------------------------------------------------------------------------- /frontend/lib/components/blog-header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Label, TextInput } from "flowbite-react"; 3 | 4 | interface Props { 5 | setSearchTerm: (searchTerm: string) => void; 6 | title?: string; 7 | description?: string; 8 | } 9 | 10 | export function BlogHeader(props: Props) { 11 | const { 12 | setSearchTerm, 13 | title = "📝 Blog", 14 | description = "This is the blog page. You can search for blog posts here or navigate through blog posts using pagination.", 15 | } = props; 16 | 17 | return ( 18 |
19 |

{title}

20 |

{description}

21 |
22 |
23 |
25 | ) => 31 | setSearchTerm(e.target.value) 32 | } 33 | /> 34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /frontend/lib/components/blog-post-preview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Card } from "flowbite-react"; 3 | import { Publication } from "../api"; 4 | import dayjs from "dayjs"; 5 | import { ROUTES, useRouter } from "../routes"; 6 | 7 | interface Props { 8 | data: Publication; 9 | } 10 | 11 | export function BlogPostPreview(props: Props) { 12 | const { data } = props; 13 | const { push } = useRouter(); 14 | 15 | return ( 16 | 21 |
22 | {data.title} {dayjs(data.created_at).locale("pt-br").format("LLLL")} 23 |
24 |

25 | {getPreview(data)} 26 |

27 | 42 |
43 | ); 44 | } 45 | 46 | function getPreview(data: Publication) { 47 | const preview = data.description ? data.description : data.body; 48 | 49 | return preview.slice(0, 50); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/lib/components/blog-preview-section.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { type Publication, useApi } from "../api"; 3 | import { useDebounce } from "../hooks"; 4 | import { BlogPostPreview } from "./blog-post-preview"; 5 | import { FullScreenLoading } from "./full-screen-loading"; 6 | import { BlogHeader } from "./blog-header"; 7 | 8 | interface IProps { 9 | data: Publication[] | undefined; 10 | } 11 | 12 | export function BlogPreviewSection(props: IProps) { 13 | const { data: blogData } = props; 14 | const [searchTerm, setSearchTerm] = useState(""); 15 | const [isLoading, setIsLoading] = useState(false); 16 | const [data, setData] = useState(blogData); 17 | const { getPublications } = useApi(); 18 | const debouncedSearchTerm = useDebounce(searchTerm, 500); 19 | 20 | useEffect( 21 | () => { 22 | if (debouncedSearchTerm) { 23 | getData(); 24 | } else { 25 | setData(blogData); 26 | } 27 | }, 28 | [debouncedSearchTerm] // Only call effect if debounced search term changes 29 | ); 30 | 31 | const getData = async () => { 32 | setIsLoading(true); 33 | setData(await getPublications({ title: debouncedSearchTerm, tag: [] })); 34 | setIsLoading(false); 35 | }; 36 | 37 | return ( 38 |
39 | 45 | {isLoading ? : null} 46 |
47 | {data && data.length > 0 48 | ? data.map((blogPost, index) => { 49 | return ; 50 | }) 51 | : null} 52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /frontend/lib/components/full-screen-loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Spinner } from "flowbite-react"; 3 | 4 | export function FullScreenLoading() { 5 | return ( 6 |
7 |
8 | 9 | Loading... 10 |
11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /frontend/lib/components/getting-started-section.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Checkbox, Label } from "flowbite-react"; 3 | import { getSecrets } from "../config"; 4 | 5 | interface Props { 6 | siteHasPublications: boolean; 7 | } 8 | 9 | export function GettingStartedSection(props: Props) { 10 | const { siteHasPublications } = props; 11 | const { authToken, isProd } = getSecrets(); 12 | 13 | return ( 14 |
15 |

✨ Getting started

16 |

17 | Welcome to your Django + React project! Here are a few things you need 18 | to do to get started: 19 |

20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 | 28 | Create a token for the default user (admin) and put it on the 29 | frontend's app .env file. Or, create a new user with the required 30 | permissions to access the API and use that token instead. This is 31 | necessary to allow the frontend to access the API. Once you've 32 | created the token and added it to the .env file, restart the 33 | frontend server 🤗 34 | 35 |
36 |
37 |
38 |
39 | 52 |
53 |
54 |
55 | 56 |
57 |
58 | 59 |
60 | 61 | Make sure the database is set up and running. Also, make sure the 62 | CDN is set up and running. This is necessary to start publishing 63 | blog posts 🤗 64 | 65 |
66 |
67 |
68 | 69 | 79 | 92 | 93 |
94 | ); 95 | } 96 | -------------------------------------------------------------------------------- /frontend/lib/components/topbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MegaMenu, Navbar } from "flowbite-react"; 3 | import { ROUTES, useRouter } from "../routes"; 4 | 5 | export function Topbar() { 6 | const { push } = useRouter(); 7 | 8 | return ( 9 | 10 |
11 | push("/")}> 12 | Django + React logo 17 | 18 | Django + React 19 | 20 | 21 | 22 | 23 | push(ROUTES.LANDING_PAGE)} 25 | className="cursor-pointer" 26 | > 27 | Home 28 | 29 | push(ROUTES.BLOG)} 32 | > 33 | Blog 34 | 35 | 36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /frontend/lib/config.ts: -------------------------------------------------------------------------------- 1 | export function getSecrets() { 2 | const nodeEnv = process.env.NODE_ENV; 3 | const authToken = process.env.AUTH_TOKEN; 4 | const isProd = nodeEnv === "production"; 5 | 6 | return { nodeEnv, authToken, isProd }; 7 | } 8 | -------------------------------------------------------------------------------- /frontend/lib/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-debounce"; 2 | -------------------------------------------------------------------------------- /frontend/lib/hooks/use-debounce.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export function useDebounce(value: string, delay: number) { 4 | // State and setters for debounced value 5 | const [debouncedValue, setDebouncedValue] = useState(value); 6 | 7 | useEffect( 8 | () => { 9 | // Update debounced value after delay 10 | const handler = setTimeout(() => { 11 | setDebouncedValue(value); 12 | }, delay); 13 | 14 | // Cancel the timeout if value changes (also on delay change or unmount) 15 | // This is how we prevent debounced value from updating if value is changed ... 16 | // .. within the delay period. Timeout gets cleared and restarted. 17 | return () => { 18 | clearTimeout(handler); 19 | }; 20 | }, 21 | [value, delay] // Only re-call effect if value or delay changes 22 | ); 23 | 24 | return debouncedValue; 25 | } 26 | -------------------------------------------------------------------------------- /frontend/lib/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, lazy } from "react"; 2 | import dayjs from "dayjs"; 3 | import LocalizedFormat from "dayjs/plugin/localizedFormat"; 4 | import { Routes, Route, BrowserRouter } from "react-router-dom"; 5 | import { ROUTES } from "./routes"; 6 | import { FullScreenLoading } from "./components/full-screen-loading"; 7 | import { Topbar } from "./components/topbar"; 8 | import "./index.css"; 9 | 10 | const LandingPage = lazy(() => import("./pages/landing")); 11 | const BlogPage = lazy(() => import("./pages/blog")); 12 | const PublicationPage = lazy(() => import("./pages/publication")); 13 | 14 | dayjs.extend(LocalizedFormat); 15 | 16 | export function Root() { 17 | return ( 18 | 19 | 20 |
21 | 22 | }> 26 | 27 | 28 | } 29 | /> 30 | }> 34 | 35 | 36 | } 37 | /> 38 | }> 42 | 43 | 44 | } 45 | /> 46 | 47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/lib/pages/blog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { 3 | type GetPaginatedPublicationsResponse, 4 | type Publication, 5 | useApi, 6 | } from "../api"; 7 | import { useDebounce } from "../hooks"; 8 | import { BlogPostPreview } from "../components/blog-post-preview"; 9 | import { FullScreenLoading } from "../components/full-screen-loading"; 10 | import { Label, TextInput } from "flowbite-react"; 11 | import { BlogHeader } from "../components/blog-header"; 12 | 13 | interface IProps { 14 | paginatedPublications?: GetPaginatedPublicationsResponse; 15 | blogPost?: Publication; 16 | } 17 | 18 | function BlogPage(props: IProps) { 19 | const { paginatedPublications, blogPost } = props; 20 | const [data, setData] = useState(); 21 | const [isLoading, setIsLoading] = useState(false); 22 | const [searchTerm, setSearchTerm] = useState(""); 23 | const { getPaginatedPublications } = useApi(); 24 | const debouncedSearchTerm = useDebounce(searchTerm, 500); 25 | 26 | useEffect(() => { 27 | getData(); 28 | }, []); 29 | 30 | useEffect( 31 | () => { 32 | if (debouncedSearchTerm) { 33 | getData({ filter: { title: debouncedSearchTerm } }); 34 | } else { 35 | if (paginatedPublications) { 36 | setData(paginatedPublications); 37 | } else { 38 | getData(); 39 | } 40 | } 41 | }, 42 | [debouncedSearchTerm] // Only call effect if debounced search term changes 43 | ); 44 | 45 | const getData = async (args?: { 46 | querystring?: string; 47 | page?: number; 48 | filter?: { title?: string; tags?: string[] }; 49 | }) => { 50 | setIsLoading(true); 51 | setData(await getPaginatedPublications(args)); 52 | setIsLoading(false); 53 | }; 54 | 55 | return ( 56 |
57 | 58 | {isLoading && } 59 | {!isLoading && data && ( 60 |
61 | {data.count > 0 && 62 | data.results.map((blogPost, index) => { 63 | return ; 64 | })} 65 |
66 | )} 67 |
68 | ); 69 | } 70 | 71 | export { BlogPage as default }; 72 | -------------------------------------------------------------------------------- /frontend/lib/pages/landing.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useEffect, useState } from "react"; 2 | import { HR, Spinner } from "flowbite-react"; 3 | import { type Publication, useApi } from "../api"; 4 | import { BlogPreviewSection } from "../components/blog-preview-section"; 5 | import { GettingStartedSection } from "../components/getting-started-section"; 6 | 7 | function LandingPage() { 8 | const [blogData, setBlogData] = useState(); 9 | const [isLoading, setIsLoading] = useState(false); 10 | const { getPublications } = useApi(); 11 | 12 | useEffect(() => { 13 | getData(); 14 | }, []); 15 | 16 | const getData = async () => { 17 | setIsLoading(true); 18 | setBlogData(await getPublications()); 19 | setIsLoading(false); 20 | }; 21 | 22 | return ( 23 | 24 | 25 |
26 | {isLoading ? : } 27 |
28 | ); 29 | } 30 | 31 | export { LandingPage as default }; 32 | -------------------------------------------------------------------------------- /frontend/lib/pages/publication.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import dayjs from "dayjs"; 3 | import { type Publication, useApi } from "../api"; 4 | import { useParams } from "react-router-dom"; 5 | import { FullScreenLoading } from "../components/full-screen-loading"; 6 | 7 | interface Props { 8 | data?: Publication; 9 | } 10 | 11 | function PublicationPage(props: Props) { 12 | const { data: publicationData } = props; 13 | const { publication } = useParams(); 14 | const [isLoading, setIsLoading] = useState(false); 15 | const [data, setData] = useState(); 16 | const { getPublication } = useApi(); 17 | 18 | useEffect(() => { 19 | getData(); 20 | }, []); 21 | 22 | const getData = async () => { 23 | if (publication) { 24 | setIsLoading(true); 25 | setData(await getPublication(publication)); 26 | setIsLoading(false); 27 | } else { 28 | setData(publicationData); 29 | } 30 | }; 31 | 32 | if (isLoading) { 33 | return ; 34 | } 35 | 36 | return ( 37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |

45 | 48 |

49 |
50 |
51 |
52 |

53 | {data?.title} 54 |

55 |
56 |

{data?.description}

57 | {data?.body} 58 |
59 | {data?.image_description} 60 |
{data?.image_description}
61 |
62 |
63 |
64 |
65 | ); 66 | } 67 | 68 | export { PublicationPage as default }; 69 | -------------------------------------------------------------------------------- /frontend/lib/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./routes"; 2 | export * from "./use-router"; 3 | -------------------------------------------------------------------------------- /frontend/lib/routes/routes.ts: -------------------------------------------------------------------------------- 1 | export enum ROUTES { 2 | LANDING_PAGE = "/", 3 | BLOG = "/blog", 4 | PUBLICATION_PAGE = "/blog/:publication", 5 | } 6 | -------------------------------------------------------------------------------- /frontend/lib/routes/use-router.ts: -------------------------------------------------------------------------------- 1 | import { 2 | useParams, 3 | useLocation, 4 | useNavigate, 5 | useMatch, 6 | } from "react-router-dom"; 7 | import queryString from "query-string"; 8 | import { useMemo } from "react"; 9 | 10 | export function useRouter() { 11 | const params = useParams(); 12 | const location = useLocation(); 13 | const navigate = useNavigate(); 14 | const match = useMatch({ 15 | end: true, 16 | path: location.pathname, 17 | }); 18 | 19 | // Return our custom router object 20 | // Memoize so that a new object is only returned if something changes 21 | return useMemo(() => { 22 | return { 23 | // For convenience add push(), replace(), pathname at top level 24 | push: navigate, 25 | replace: (to: string) => 26 | navigate(to, { 27 | replace: true, 28 | }), 29 | pathname: location.pathname, 30 | // Merge params and parsed query string into single "query" object 31 | // so that they can be used interchangeably. 32 | // Example: /:topic?sort=popular -> { topic: "react", sort: "popular" } 33 | query: { 34 | ...queryString.parse(location.search), // Convert string to object 35 | ...params, 36 | }, 37 | // Include match, location, history objects so we have 38 | // access to extra React Router functionality if needed. 39 | match, 40 | location, 41 | history, 42 | }; 43 | }, [params, match, location, navigate]); 44 | } 45 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-react-typescript-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "main": "index.js", 6 | "repository": "https://github.com/marcelovicentegc/django-react-typescript.git", 7 | "author": "Marcelo Cardoso ", 8 | "license": "MIT", 9 | "husky": { 10 | "hooks": { 11 | "pre-commit": "npm test", 12 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 13 | } 14 | }, 15 | "scripts": { 16 | "unused": "pnpm unimported", 17 | "postinstall": "postcss ./tailwind.css -o ./lib/index.css", 18 | "build": "cross-env webpack --config webpack.config.js", 19 | "dev:react": "cross-env webpack-dev-server --progress --host 0.0.0.0", 20 | "dev:css": "postcss ./tailwind.css -o ./lib/index.css --watch", 21 | "dev": "concurrently -n css,react \"pnpm run dev:css\" \"pnpm run dev:react\"", 22 | "test:static": "tsc -p tsconfig.json --noEmit", 23 | "test": "jest \"(/__tests__/.)*\\.tsx?$\" --coverage --colors --silent", 24 | "test:watch": "jest \"(/__tests__/.)*\\.tsx?$\" --coverage --colors --watch" 25 | }, 26 | "dependencies": { 27 | "@heroicons/react": "^2.1.4", 28 | "@tailwindcss/forms": "^0.5.7", 29 | "@tailwindcss/typography": "^0.5.13", 30 | "dayjs": "^1.8.29", 31 | "react": "^18.3.1", 32 | "react-dom": "^18.3.1", 33 | "flowbite-react": "^0.10.1", 34 | "query-string": "^6.13.0", 35 | "react-router-dom": "^6.23.1", 36 | "tailwindcss": "^3.4.4" 37 | }, 38 | "devDependencies": { 39 | "@babel/core": "^7.24.7", 40 | "@babel/preset-env": "^7.24.7", 41 | "@babel/preset-react": "^7.24.7", 42 | "@types/node": "^20.14.6", 43 | "@types/react": "^18.3.3", 44 | "@types/react-dom": "^18.3.0", 45 | "autoprefixer": "^10.4.19", 46 | "babel-loader": "^9.1.3", 47 | "copy-webpack-plugin": "^5.1.1", 48 | "cross-env": "^6.0.3", 49 | "source-map-loader": "^0.2.4", 50 | "ts-loader": "^6.2.1", 51 | "internal-ip": "^5.0.0", 52 | "concurrently": "^8.2.2", 53 | "css-loader": "^3.6.0", 54 | "dotenv-webpack": "^8.1.0", 55 | "html-webpack-plugin": "^5.6.0", 56 | "postcss": "^8.4.38", 57 | "postcss-cli": "^11.0.0", 58 | "postcss-loader": "^8.1.1", 59 | "style-loader": "^4.0.0", 60 | "typescript": "^5.4.5", 61 | "unimported": "^1.31.1", 62 | "webpack": "^5.92.1", 63 | "webpack-cli": "^5.1.4", 64 | "webpack-dev-server": "^5.0.4", 65 | "webpack-manifest-plugin": "^5.0.0", 66 | "workbox-webpack-plugin": "^7.1.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwindcss = require("tailwindcss"); 2 | 3 | module.exports = { 4 | plugins: [tailwindcss("./tailwind.config.js"), require("autoprefixer")], 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/public/logo-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/frontend/public/logo-32.png -------------------------------------------------------------------------------- /frontend/public/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/frontend/public/logo-512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "DRT", 3 | "name": "Django-React-Typescript", 4 | "icons": [ 5 | { 6 | "src": "/static/frontend/logo-512.png", 7 | "sizes": "512x512", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "/static/frontend/index.html", 12 | "display": "standalone", 13 | "theme_color": "#0C4B33", 14 | "background_color": "#0C4B33" 15 | } 16 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: * 3 | Crawl-delay: 10 4 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const flowbite = require("flowbite-react/tailwind"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | relative: true, 6 | content: [ 7 | "./lib/components/*.js", 8 | "./lib/pages/*.js", 9 | "./templates/frontend/**/*.html", 10 | flowbite.content(), 11 | ], 12 | theme: { 13 | extend: {}, 14 | }, 15 | plugins: [flowbite.plugin()], 16 | safelist: [ 17 | { 18 | pattern: 19 | /(min|bg|mx|max|px|h|items|justify|w|ml|space|text|rounded|font|py|p|sr|inset|ml|right|mt|origin|shadow|ring|mr|inline|leading|flex|tracking|self|whitespace|grid)-/, 20 | variants: ["sm", "md", "lg"], 21 | }, 22 | { 23 | pattern: /(absolute|flex|hidden|relative|block)/, 24 | }, 25 | ], 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /frontend/templates/frontend/dev/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/templates/frontend/index.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/tests.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcelovicentegc/django-react-typescript/e512919cc4ef2676d61eefe26298ea725f502728/frontend/tests.py -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./static/frontend/", 4 | "target": "es5", 5 | "jsx": "react", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "noImplicitAny": true, 9 | "declaration": true, 10 | "downlevelIteration": true, 11 | "removeComments": true, 12 | "experimentalDecorators": true, 13 | "allowSyntheticDefaultImports": true, 14 | "module": "esnext", 15 | "lib": ["es6", "dom", "dom.iterable"] 16 | }, 17 | "include": ["./lib/**/*", "./index.tsx"], 18 | "exclude": ["node_modules", "static/frontend", "**/__tests__/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /frontend/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-prettier", 6 | "tslint-plugin-prettier", 7 | "tslint-react" 8 | ], 9 | "jsRules": {}, 10 | "rules": { 11 | "prettier": false, 12 | "interface-over-type-literal": false, 13 | "array-type": false, 14 | "object-literal-sort-keys": false, 15 | "curly": false, 16 | "no-console": false, 17 | "jsx-no-lambda": false, 18 | "jsx-no-multiline-js": false, 19 | "ordered-imports": false, 20 | "no-shadowed-variable": false, 21 | "interface-name": false, 22 | "jsx-boolean-value": false, 23 | "no-var-requires": false, 24 | "radix": false 25 | }, 26 | "rulesDirectory": [] 27 | } 28 | -------------------------------------------------------------------------------- /frontend/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from . import views 3 | 4 | urlpatterns = [ 5 | re_path( 6 | r"^blog$|^blog/(?P.+)$|^$", views.spa_and_admin_handler, name="frontend" 7 | ), 8 | re_path(r"^.*\.(js|png)$", views.frontend_files_handler, name="frontend"), 9 | ] 10 | -------------------------------------------------------------------------------- /frontend/views.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from django.shortcuts import render 3 | from django.shortcuts import redirect 4 | from django.http import StreamingHttpResponse 5 | from wsgiref.util import is_hop_by_hop 6 | 7 | 8 | class ProxyHttpResponse(StreamingHttpResponse): 9 | """ 10 | Proxies a response from an upstream server to the client. 11 | """ 12 | 13 | def __init__(self, url, headers=None, **kwargs): 14 | upstream = requests.get(url, stream=True, headers=headers) 15 | 16 | kwargs.setdefault("content_type", upstream.headers.get("content-type")) 17 | kwargs.setdefault("status", upstream.status_code) 18 | kwargs.setdefault("reason", upstream.reason) 19 | 20 | super().__init__(upstream.raw, **kwargs) 21 | 22 | for name, value in upstream.headers.items(): 23 | if not is_hop_by_hop(name): 24 | self[name] = value 25 | 26 | 27 | def spa_and_admin_handler(request): 28 | """ 29 | This view is used to serve the React application and the Django 30 | admin panel. The React application is served when the request 31 | path does not start with '/admin', and the Django admin panel 32 | is served when the request path starts with '/admin'. 33 | """ 34 | pathname = request.META.get("PATH_INFO", None) 35 | 36 | if pathname.startswith("/admin"): 37 | return redirect(pathname) 38 | 39 | return render(request, "frontend/index.html") 40 | 41 | 42 | def frontend_files_handler(request, _): 43 | """ 44 | This view is used to serve files requested dynamically by the 45 | React application from the backend server, since the client-side 46 | code is not aware of where the files are located in the backend 47 | and expects them to be located on the root. 48 | """ 49 | pathname = request.META.get("PATH_INFO", None) 50 | url = request.build_absolute_uri() 51 | 52 | backend_relative_pathname = pathname.replace("/", "/static/frontend/") 53 | url = url.replace(pathname, backend_relative_pathname) 54 | 55 | print(url) 56 | 57 | return ProxyHttpResponse(url, headers=request.headers) 58 | -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const internalIp = require("internal-ip"); 2 | const path = require("path"); 3 | const webpack = require("webpack"); 4 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 5 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 6 | const Dotenv = require("dotenv-webpack"); 7 | 8 | const PUBLIC_PATH = "/static/frontend/"; 9 | 10 | const PORT = 4000; 11 | 12 | const PRODUCTION_MODE = process.env.NODE_ENV === "production"; 13 | 14 | if (!PRODUCTION_MODE) { 15 | console.log( 16 | `\n\nWhen done building, your application will be available at http://${internalIp.v4.sync()}:${PORT}\n\n\n` 17 | ); 18 | } 19 | 20 | module.exports = { 21 | context: __dirname, 22 | mode: process.env.NODE_ENV || "development", 23 | entry: ["./index.tsx"], 24 | output: { 25 | filename: "index.js", 26 | path: path.resolve(__dirname, PUBLIC_PATH.replace("/", "")), 27 | publicPath: PRODUCTION_MODE ? PUBLIC_PATH : "/", 28 | }, 29 | devtool: "source-map", 30 | resolve: { 31 | extensions: [".ts", ".tsx", ".mjs", ".js", ".json", ".css"], 32 | alias: { 33 | "@": path.resolve("lib"), 34 | }, 35 | }, 36 | plugins: [ 37 | new HtmlWebpackPlugin({ 38 | template: path.resolve( 39 | __dirname, 40 | "templates", 41 | "frontend", 42 | PRODUCTION_MODE ? "" : "dev", 43 | "index.html" 44 | ), 45 | filename: "index.html", 46 | }), 47 | new CopyWebpackPlugin([{ from: "public" }]), 48 | new webpack.HotModuleReplacementPlugin(), 49 | new webpack.optimize.AggressiveMergingPlugin(), 50 | new Dotenv(), 51 | ], 52 | module: { 53 | rules: [ 54 | { 55 | test: /\.tsx?$/, 56 | loader: "ts-loader", 57 | options: { 58 | configFile: "tsconfig.json", 59 | }, 60 | exclude: [/node_modules/], 61 | }, 62 | { 63 | test: /\.css$/, 64 | use: ["style-loader", "css-loader"], 65 | }, 66 | { 67 | enforce: "pre", 68 | test: /\.js$/, 69 | loader: "source-map-loader", 70 | exclude: [/node_modules/], 71 | }, 72 | { 73 | test: /\.(woff2|png|jp(e*)g|gif|svg)$/, 74 | loader: "file-loader", 75 | options: { 76 | name: "icons|fonts/[name].[ext]", 77 | outputPath: PUBLIC_PATH, 78 | }, 79 | }, 80 | ], 81 | }, 82 | devServer: { 83 | watchFiles: `.${PUBLIC_PATH}`, 84 | compress: true, 85 | port: PORT, 86 | hot: true, 87 | open: true, 88 | historyApiFallback: true, 89 | allowedHosts: ["127.0.0.0", "localhost"], 90 | }, 91 | }; 92 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main(): 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings.base") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "django-react-typescript", 3 | "version": "1.0.1", 4 | "private": true, 5 | "description": "This is an non-opinionated Django + React boilerplate built with great development experience and easy deployment in mind.", 6 | "scripts": { 7 | "bootstrap": "concurrently -n global,backend,frontend \"pnpm i\" \"poetry install\" \"cd frontend && pnpm i\"", 8 | "setup:env": "sh ./scripts/setup_env.sh", 9 | "test:static": "poetry run ruff check", 10 | "format": "poetry run ruff format", 11 | "build:frontend": "cd frontend && pnpm i && pnpm run build", 12 | "dev:db:up": "docker compose -f ./docker-compose-dev-db.yml up", 13 | "dev:db:migrate": "poetry run python3 manage.py migrate", 14 | "dev:db:makemigrations": "poetry run python3 manage.py makemigrations", 15 | "dev:backend": "pnpm run build:frontend && poetry run python3 manage.py runserver", 16 | "dev:backend:db": "concurrently -n db,backend \"pnpm run dev:db:up\" \"wait-on tcp:5432 && pnpm run dev:backend\"", 17 | "dev:frontend": "cd frontend && pnpm i && pnpm run dev", 18 | "dev": "concurrently -n backend,frontend \"pnpm run dev:backend\" \"pnpm run dev:frontend\"", 19 | "dev:full": "concurrently -n db,backend,frontend \"pnpm run dev:db:up\" \"wait-on tcp:5432 && pnpm run dev:backend\" \"pnpm run dev:frontend\"", 20 | "release": "semantic-release", 21 | "prepare": "husky" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/marcelovicentegc/django-react-typescript.git" 26 | }, 27 | "author": "Marcelo Cardoso", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/marcelovicentegc/django-react-typescript/issues" 31 | }, 32 | "homepage": "https://github.com/marcelovicentegc/django-react-typescript#readme", 33 | "devDependencies": { 34 | "@semantic-release/changelog": "^6.0.3", 35 | "@semantic-release/git": "^10.0.1", 36 | "concurrently": "^8.2.2", 37 | "husky": "^9.0.11", 38 | "semantic-release": "^24.0.0", 39 | "wait-on": "^7.2.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-react-typescript" 3 | description = "This is an non-opinionated Django + React boilerplate built with great development experience and easy deployment in mind." 4 | authors = ["marcelovicentegc "] 5 | license = "MIT" 6 | readme = "README.md" 7 | package-mode = false 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.12" 11 | django = "^5.0.6" 12 | djangorestframework = "^3.15.1" 13 | sentry-sdk = "^2.5.1" 14 | gunicorn = "^22.0.0" 15 | pillow = "^10.3.0" 16 | django-cors-headers = "^4.3.1" 17 | cloudinary = "^1.40.0" 18 | python-dotenv = "^1.0.1" 19 | django-filter = "^24.2" 20 | django-better-admin-arrayfield = "^1.4.2" 21 | twilio = "^9.1.1" 22 | psycopg2-binary = "^2.9.9" 23 | 24 | [tool.poetry.group.dev.dependencies] 25 | ruff = "^0.4.10" 26 | 27 | [build-system] 28 | requires = ["poetry-core"] 29 | build-backend = "poetry.core.masonry.api" 30 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Exclude a variety of commonly ignored directories. 2 | exclude = [ 3 | ".bzr", 4 | ".direnv", 5 | ".eggs", 6 | ".git", 7 | ".git-rewrite", 8 | ".hg", 9 | ".ipynb_checkpoints", 10 | ".mypy_cache", 11 | ".nox", 12 | ".pants.d", 13 | ".pyenv", 14 | ".pytest_cache", 15 | ".pytype", 16 | ".ruff_cache", 17 | ".svn", 18 | ".tox", 19 | ".venv", 20 | ".vscode", 21 | "__pypackages__", 22 | "_build", 23 | "buck-out", 24 | "build", 25 | "dist", 26 | "node_modules", 27 | "site-packages", 28 | "venv", 29 | "backend/admin/__init__.py" 30 | ] 31 | 32 | # Same as Black. 33 | line-length = 88 34 | indent-width = 4 35 | 36 | # Assume Python 3.8 37 | target-version = "py38" 38 | 39 | [lint] 40 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 41 | select = ["E4", "E7", "E9", "F"] 42 | ignore = ["F403", "E402"] 43 | 44 | # Allow fix for all enabled rules (when `--fix`) is provided. 45 | fixable = ["ALL"] 46 | unfixable = [] 47 | 48 | # Allow unused variables when underscore-prefixed. 49 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 50 | 51 | [format] 52 | # Like Black, use double quotes for strings. 53 | quote-style = "double" 54 | 55 | # Like Black, indent with spaces, rather than tabs. 56 | indent-style = "space" 57 | 58 | # Like Black, respect magic trailing commas. 59 | skip-magic-trailing-comma = false 60 | 61 | # Like Black, automatically detect the appropriate line ending. 62 | line-ending = "auto" -------------------------------------------------------------------------------- /scripts/setup_env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Creating .env with expected development environment values" 4 | 5 | touch .env 6 | echo "SECRET_KEY=$(base64 /dev/urandom | head -c50)\nDB_HOST=127.0.0.1\nDB_NAME=dev_db\nDB_USER=admin\nDB_PASSWORD=root\nDB_PORT=5432\nSMTP_HOST_USER=\nSMTP_HOST_PASSWORD=\nTEST=" > .env 7 | 8 | echo "Done" --------------------------------------------------------------------------------