├── .gitignore ├── README.md ├── backend ├── .env.sample ├── .gitignore ├── Dockerfile ├── gunicorn.conf.py ├── railway.json ├── requirements.dev.txt ├── requirements.txt └── src │ ├── accounts │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py │ ├── ai │ ├── __init__.py │ └── api.py │ ├── cfehome │ ├── __init__.py │ ├── api.py │ ├── asgi.py │ ├── installed.py │ ├── settings.py │ ├── urls.py │ ├── views.py │ └── wsgi.py │ ├── documents │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── apps.py │ ├── exceptions.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_docuser.py │ │ └── __init__.py │ ├── models.py │ ├── schemas.py │ ├── services.py │ ├── tests.py │ └── views.py │ ├── googler │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── oauth.py │ ├── schemas.py │ ├── security.py │ ├── services.py │ ├── tests.py │ └── urls.py │ ├── helpers │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── auth │ │ │ ├── __init__.py │ │ │ ├── controllers.py │ │ │ ├── permissions.py │ │ │ └── schemas.py │ │ └── users │ │ │ ├── __init__.py │ │ │ └── schemas.py │ └── dotenv │ │ ├── __init__.py │ │ └── loader.py │ ├── manage.py │ ├── profiles │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── signals.py │ ├── tests.py │ └── views.py │ ├── requirements │ ├── dev.in │ └── prod.in │ ├── staticfiles │ └── .git-keep │ └── templates │ └── .git-keep ├── compose.yaml ├── frontend ├── .env.sample ├── .gitignore ├── Dockerfile ├── Dockerfile.demo ├── components.json ├── eslint.config.mjs ├── jsconfig.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── django-nextjs-favicon.png │ ├── django-nextjs-text.svg │ ├── django-nextjs.svg │ ├── file.svg │ ├── globe.svg │ ├── login.png │ ├── signup.png │ └── window.svg ├── railway.demo.json ├── railway.json ├── src │ ├── app │ │ ├── api │ │ │ ├── [...path] │ │ │ │ └── route.js │ │ │ ├── backend │ │ │ │ └── healthz │ │ │ │ │ └── route.js │ │ │ ├── ckeditor │ │ │ │ └── route.js │ │ │ ├── google │ │ │ │ └── callback │ │ │ │ │ └── route.js │ │ │ ├── health │ │ │ │ └── route.js │ │ │ ├── login │ │ │ │ └── route.js │ │ │ ├── logout │ │ │ │ └── route.js │ │ │ └── signup │ │ │ │ └── route.js │ │ ├── docs │ │ │ ├── [docId] │ │ │ │ └── page.js │ │ │ ├── create │ │ │ │ └── page.js │ │ │ └── page.js │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── google │ │ │ ├── GoogleLoginButton.jsx │ │ │ └── callback │ │ │ │ └── page.jsx │ │ ├── healthz │ │ │ └── route.js │ │ ├── layout.js │ │ ├── login │ │ │ └── page.jsx │ │ ├── logout │ │ │ └── page.jsx │ │ ├── page.js │ │ └── signup │ │ │ └── page.jsx │ ├── components │ │ ├── apiProvider.jsx │ │ ├── authProvider.jsx │ │ ├── editor │ │ │ ├── DocEditor.jsx │ │ │ └── docEditor.css │ │ ├── layout │ │ │ ├── AccountDropdown.jsx │ │ │ ├── BaseLayout.jsx │ │ │ ├── BrandLink.jsx │ │ │ ├── MobileNavbar.jsx │ │ │ ├── NavLinks.jsx │ │ │ └── Navbar.jsx │ │ ├── themeProvider.jsx │ │ ├── ui │ │ │ ├── button.jsx │ │ │ ├── card.jsx │ │ │ ├── dropdown-menu.jsx │ │ │ ├── input.jsx │ │ │ ├── label.jsx │ │ │ ├── sheet.jsx │ │ │ ├── table.jsx │ │ │ └── textarea.jsx │ │ └── useMySWR.jsx │ └── lib │ │ ├── auth.js │ │ ├── fetcher.js │ │ ├── getApiEndpoint.js │ │ ├── tokenFetcher.js │ │ ├── urlJoin.js │ │ └── utils.js └── tailwind.config.js └── rav.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .env* 133 | !.env.sample 134 | .venv 135 | env/ 136 | venv/ 137 | ENV/ 138 | env.bak/ 139 | venv.bak/ 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | # mypy 152 | .mypy_cache/ 153 | .dmypy.json 154 | dmypy.json 155 | 156 | # Pyre type checker 157 | .pyre/ 158 | 159 | # pytype static type analyzer 160 | .pytype/ 161 | 162 | # Cython debug symbols 163 | cython_debug/ 164 | 165 | # PyCharm 166 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 167 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 168 | # and can be added to the global gitignore or merged into this file. For a more nuclear 169 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 170 | #.idea/ 171 | 172 | # Ruff stuff: 173 | .ruff_cache/ 174 | 175 | # PyPI configuration file 176 | .pypirc 177 | 178 | 179 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 180 | 181 | # dependencies 182 | frontend/.next 183 | frontend/node_modules 184 | /node_modules 185 | /.pnp 186 | .pnp.* 187 | .yarn/* 188 | !.yarn/patches 189 | !.yarn/plugins 190 | !.yarn/releases 191 | !.yarn/versions 192 | 193 | # testing 194 | /coverage 195 | 196 | # next.js 197 | /.next/ 198 | /out/ 199 | 200 | # production 201 | /build 202 | 203 | # misc 204 | .DS_Store 205 | *.pem 206 | 207 | # debug 208 | npm-debug.log* 209 | yarn-debug.log* 210 | yarn-error.log* 211 | .pnpm-debug.log* 212 | 213 | 214 | # vercel 215 | .vercel 216 | 217 | # typescript 218 | *.tsbuildinfo 219 | next-env.d.ts 220 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Building a Modern Docs Platform with Django, Next.js, CKEditor, and Google OAuth 2 | 3 | ⭐️ Thanks to [CKEditor](https://ckeditor.com/?utm_campaign=devrel_cfe_course&utm_source=youtube&utm_medium=referral&utm_term=social) parterning with us on this course! 4 | 5 | ## Course Resources 6 | - 🎥 **YouTube video**: [Watch the course](https://youtu.be/OGCE3OUO4G8) 7 | - 💽 **GitHub code**: [Project repository](https://github.com/codingforentrepreneurs/google-docs-with-django-nextjs) 8 | - 🐍 **Boilerplate code** [Django x Next.js](https://djangonextjs.com) 9 | 10 | ## Course Topics 11 | 12 | ### Full-Stack Integration 13 | - ✅ Full-stack web development with Django and Next.js 14 | - ✅ Setting up Django backend with production-ready configuration 15 | - ✅ Configuring Next.js frontend for modern user experience 16 | - ✅ PostgreSQL database setup via Docker Compose 17 | 18 | ### Authentication & User Management 19 | - ✅ Complete Google OAuth implementation from scratch 20 | - ✅ OAuth state and PKCE token generation and management 21 | - ✅ Django caching for secure OAuth token handling 22 | - ✅ Custom Django user model with email-first authentication 23 | - ✅ User registration flows for both email and Google login 24 | - ✅ Token verification and refresh mechanisms 25 | 26 | ### Document Editor & Collaboration 27 | - ✅ CKEditor integration for rich document editing 28 | - ✅ Real-time collaboration with Django-based users via CKEditor 29 | - ✅ TailwindCSS configuration with CKEditor 30 | - ✅ AI assistance integration with custom adapters 31 | - ✅ Multi-user real-time document collaboration 32 | 33 | ### Security & API Development 34 | - ✅ JWT token signatures for secure user authentication 35 | - ✅ API endpoints for user tokens and document management 36 | 37 | ## Prerequisites 38 | 39 | ### Python 40 | - Knowledge of classes, functions, async/await, and working with HTTP requests 41 | - Understanding of virtual environments and package management 42 | 43 | ### Django Basics 44 | - Familiarity with views, URL routing, models, and the Django ORM 45 | - Understanding Django's authentication system and middleware 46 | 47 | ### JavaScript and React 48 | - ES6+ features, async/await, and working with APIs 49 | - React hooks, context, and component lifecycle 50 | 51 | ### Authentication Knowledge 52 | - Basic understanding of OAuth authentication flows 53 | - Knowledge of JWT tokens and authentication mechanisms 54 | 55 | --- 56 | 57 | This comprehensive course provides everything you need to build a production-ready Google-docs-like collaboration platform with modern authentication and real-time editing & collaboration features. 58 | 59 | 60 | 61 | ## Video Chapters 62 | 63 | ### Google Docs with Next.js, Django & CKEditor 64 | 65 | - [Google Docs with Next.js, Django & CKEditor](https://youtu.be/OGCE3OUO4G8?t=9) 66 | - [Getting started - Tech Stack](https://youtu.be/OGCE3OUO4G8?t=252) 67 | 68 | ### Django x Next.js 69 | - [Starting Django x Nextjs Integration](https://youtu.be/OGCE3OUO4G8?t=655) 70 | - [Django Backend Baseline Setup](https://youtu.be/OGCE3OUO4G8?t=728) 71 | - [Nextjs Frontend Setup](https://youtu.be/OGCE3OUO4G8?t=1009) 72 | - [Postgres Database via Docker Compose](https://youtu.be/OGCE3OUO4G8?t=1139) 73 | - [Register Users with Django & Next.js](https://youtu.be/OGCE3OUO4G8?t=1443) 74 | - [We Need Google Auth](https://youtu.be/OGCE3OUO4G8?t=1765) 75 | 76 | ### Google Login from Scratch with Django and OAuth 77 | - [Mini Django Project for Google Login](https://youtu.be/OGCE3OUO4G8?t=1960) 78 | - [Minimal Django Project Setup](https://youtu.be/OGCE3OUO4G8?t=2142) 79 | - [Google Cloud and the Google Auth Platform](https://youtu.be/OGCE3OUO4G8?t=2363) 80 | - [OAuth Flow + Django Views](https://youtu.be/OGCE3OUO4G8?t=2982) 81 | - [Generate OAuth State and PKCE Tokes](https://youtu.be/OGCE3OUO4G8?t=3231) 82 | - [OAuth Callback URL](https://youtu.be/OGCE3OUO4G8?t=3382) 83 | - [Genereate the Google OAuth Login URL](https://youtu.be/OGCE3OUO4G8?t=3598) 84 | - [Django Caching for OAuth State & PKCE](https://youtu.be/OGCE3OUO4G8?t=4083) 85 | - [Finalize Login URL with Google Auth Client](https://youtu.be/OGCE3OUO4G8?t=4352) 86 | - [Handle the Google OAuth Callback](https://youtu.be/OGCE3OUO4G8?t=4751) 87 | - [Verify Callback Token](https://youtu.be/OGCE3OUO4G8?t=5258) 88 | - [Create Django User from Google User](https://youtu.be/OGCE3OUO4G8?t=5638) 89 | - [Scopes for Google OAuth](https://youtu.be/OGCE3OUO4G8?t=6190) 90 | - [Finalize Django Login for Google User](https://youtu.be/OGCE3OUO4G8?t=6477) 91 | - [Unlocking Django App Portability](https://youtu.be/OGCE3OUO4G8?t=6746) 92 | 93 | ### Customize Django User Model with Google Login 94 | - [Email-based User Model in Django](https://youtu.be/OGCE3OUO4G8?t=7090) 95 | - [Before you replace the User model](https://youtu.be/OGCE3OUO4G8?t=7200) 96 | - [Replace the Default User Model](https://youtu.be/OGCE3OUO4G8?t=7742) 97 | - [Customizing the Custom User Model](https://youtu.be/OGCE3OUO4G8?t=8170) 98 | - [Modify Next.js Login Form](https://youtu.be/OGCE3OUO4G8?t=8517) 99 | - [Improved Login Flow from Django API](https://youtu.be/OGCE3OUO4G8?t=8811) 100 | - [Sign Up Flow for Email-based Users](https://youtu.be/OGCE3OUO4G8?t=9343) 101 | - [User Display Name](https://youtu.be/OGCE3OUO4G8?t=9563) 102 | - [Google Login API Endpoints](https://youtu.be/OGCE3OUO4G8?t=9739) 103 | - [Google Auth Client and Django Config](https://youtu.be/OGCE3OUO4G8?t=10199) 104 | - [Next.js Google Login Button & Redirect](https://youtu.be/OGCE3OUO4G8?t=10630) 105 | - [Handle the Google Callback in Next.js](https://youtu.be/OGCE3OUO4G8?t=10946) 106 | - [Active vs Inactive Accounts](https://youtu.be/OGCE3OUO4G8?t=11352) 107 | - [Verify User in Next.js with TokenFetcher](https://youtu.be/OGCE3OUO4G8?t=11533) 108 | - [Perform Token Refresh](https://youtu.be/OGCE3OUO4G8?t=12032) 109 | 110 | ### Basic Docs 111 | - [Documents App and Basic Model](https://youtu.be/OGCE3OUO4G8?t=12625) 112 | - [Doc Model Schema with Django Ninja](https://youtu.be/OGCE3OUO4G8?t=12943) 113 | - [API List View for User Documents](https://youtu.be/OGCE3OUO4G8?t=13092) 114 | - [Caching to Speed Up Django QuerySets](https://youtu.be/OGCE3OUO4G8?t=13439) 115 | - [List View for Docs in Next.js](https://youtu.be/OGCE3OUO4G8?t=13799) 116 | - [Client Side Login Required with useSWR](https://youtu.be/OGCE3OUO4G8?t=14203) 117 | - [Client-Side Docs Detail View](https://youtu.be/OGCE3OUO4G8?t=14472) 118 | - [Dynamic URL Routing in Django Ninja](https://youtu.be/OGCE3OUO4G8?t=14778) 119 | - [Get Document Detail Service](https://youtu.be/OGCE3OUO4G8?t=14907) 120 | - [Exception Handling for Permissions and Not Found](https://youtu.be/OGCE3OUO4G8?t=15045) 121 | - [API Endpoints for Updating Documents](https://youtu.be/OGCE3OUO4G8?t=15538) 122 | - [Frontend Form to Update Document](https://youtu.be/OGCE3OUO4G8?t=15849) 123 | - [Create Documents in Backend API & Frontend](https://youtu.be/OGCE3OUO4G8?t=16568) 124 | - [Challenge: Create a Delete View](https://youtu.be/OGCE3OUO4G8?t=17213) 125 | 126 | ### CKEditor as Docs Editor 127 | - [Intro to CKEditor](https://youtu.be/OGCE3OUO4G8?t=17300) 128 | - [Install CKEditor in NextJS](https://youtu.be/OGCE3OUO4G8?t=17417) 129 | - [Swap Textarea with CKEditor](https://youtu.be/OGCE3OUO4G8?t=17862) 130 | - [Save CKEditor Contents to Database](https://youtu.be/OGCE3OUO4G8?t=18086) 131 | - [Adding new features & the CKEditor Builder](https://youtu.be/OGCE3OUO4G8?t=18442) 132 | - [Using TailwindCSS with CKEditor](https://youtu.be/OGCE3OUO4G8?t=18684) 133 | - [Autosave with CKEditor](https://youtu.be/OGCE3OUO4G8?t=18940) 134 | - [Adding Any Standard Plugin](https://youtu.be/OGCE3OUO4G8?t=19353) 135 | - [Managing the CKEditor License Key](https://youtu.be/OGCE3OUO4G8?t=19490) 136 | - [Using AI Assistant Plugin with a Custom Adapter](https://youtu.be/OGCE3OUO4G8?t=19745) 137 | - [Proxied AI Responses with Django](https://youtu.be/OGCE3OUO4G8?t=20137) 138 | 139 | ### Multi-User Collaboration 140 | - [Collaboration Basics with CKEditor](https://youtu.be/OGCE3OUO4G8?t=20726) 141 | - [Multiple Users for the Django Documents Model](https://youtu.be/OGCE3OUO4G8?t=21112) 142 | - [Creating the CKEditor User Token Payload](https://youtu.be/OGCE3OUO4G8?t=21571) 143 | - [JWT Token Signature for CKEditor](https://youtu.be/OGCE3OUO4G8?t=22109) 144 | - [Django Ninja API Endpoint for CKEditor User Tokens](https://youtu.be/OGCE3OUO4G8?t=22552) 145 | - [CKEditor Loading our Custom JWT User Tokens](https://youtu.be/OGCE3OUO4G8?t=22713) 146 | - [A final challenge](https://youtu.be/OGCE3OUO4G8?t=23067) 147 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /backend/.env.sample: -------------------------------------------------------------------------------- 1 | # your nextjs frontend url 2 | # use a live url for production 3 | FRONTEND_URL=http://localhost:3000 4 | # python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())' 5 | DJANGO_SECRET_KEY=django-insecure-*s_thxsz7nu5nx^bjc9xlpdbu3sv#fxn$2sp-6u_rb)a1hu+rd 6 | DJANGO_DEBUG=True 7 | DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 8 | DJANGO_APPEND_SLASH=True 9 | DJANGO_SITE_ID=1 10 | GOOGLE_CLIENT_ID=abc 11 | GOOGLE_SECRET_KEY=abc 12 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | .DS_Store 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # UV 101 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | #uv.lock 105 | 106 | # poetry 107 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 108 | # This is especially recommended for binary packages to ensure reproducibility, and is more 109 | # commonly ignored for libraries. 110 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 111 | #poetry.lock 112 | 113 | # pdm 114 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 115 | #pdm.lock 116 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 117 | # in version control. 118 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 119 | .pdm.toml 120 | .pdm-python 121 | .pdm-build/ 122 | 123 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 124 | __pypackages__/ 125 | 126 | # Celery stuff 127 | celerybeat-schedule 128 | celerybeat.pid 129 | 130 | # SageMath parsed files 131 | *.sage.py 132 | 133 | # Environments 134 | !.env.example 135 | .env 136 | .venv 137 | env/ 138 | venv/ 139 | ENV/ 140 | env.bak/ 141 | venv.bak/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | .spyproject 146 | 147 | # Rope project settings 148 | .ropeproject 149 | 150 | # mkdocs documentation 151 | /site 152 | 153 | # mypy 154 | .mypy_cache/ 155 | .dmypy.json 156 | dmypy.json 157 | 158 | # Pyre type checker 159 | .pyre/ 160 | 161 | # pytype static type analyzer 162 | .pytype/ 163 | 164 | # Cython debug symbols 165 | cython_debug/ 166 | 167 | # PyCharm 168 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 169 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 170 | # and can be added to the global gitignore or merged into this file. For a more nuclear 171 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 172 | #.idea/ 173 | 174 | # Ruff stuff: 175 | .ruff_cache/ 176 | 177 | # PyPI configuration file 178 | .pypirc 179 | 180 | 181 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 182 | 183 | # dependencies 184 | frontend/.next 185 | frontend/node_modules 186 | /node_modules 187 | /.pnp 188 | .pnp.* 189 | .yarn/* 190 | !.yarn/patches 191 | !.yarn/plugins 192 | !.yarn/releases 193 | !.yarn/versions 194 | 195 | # testing 196 | /coverage 197 | 198 | # next.js 199 | /.next/ 200 | /out/ 201 | 202 | # production 203 | /build 204 | 205 | # misc 206 | .DS_Store 207 | *.pem 208 | 209 | # debug 210 | npm-debug.log* 211 | yarn-debug.log* 212 | yarn-error.log* 213 | .pnpm-debug.log* 214 | 215 | 216 | # vercel 217 | .vercel 218 | 219 | # typescript 220 | *.tsbuildinfo 221 | next-env.d.ts 222 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | ARG PYTHON_VERSION=3.12-slim-bullseye 3 | FROM python:${PYTHON_VERSION} AS builder 4 | 5 | # Combine venv creation and build dependencies 6 | RUN python -m venv /opt/venv && \ 7 | apt-get update && apt-get install -y \ 8 | libpq-dev \ 9 | zlib1g-dev \ 10 | libjpeg-dev \ 11 | libfreetype6-dev \ 12 | python3-dev \ 13 | gcc \ 14 | g++ \ 15 | && rm -rf /var/lib/apt/lists/* 16 | 17 | ENV PATH=/opt/venv/bin:$PATH 18 | 19 | # Install packages and cleanup in same layer 20 | COPY requirements.txt /tmp/ 21 | RUN pip install --upgrade pip && \ 22 | pip install -r /tmp/requirements.txt gunicorn && \ 23 | rm -rf /root/.cache/pip/* /tmp/requirements.txt 24 | 25 | # Final stage 26 | FROM python:${PYTHON_VERSION} 27 | 28 | # Copy virtual env from builder 29 | COPY --from=builder /opt/venv /opt/venv 30 | ENV PATH=/opt/venv/bin:$PATH 31 | 32 | # Install only runtime dependencies 33 | RUN apt-get update && apt-get install -y \ 34 | libpq5 \ 35 | curl \ 36 | libcairo2 \ 37 | && rm -rf /var/lib/apt/lists/* 38 | 39 | # Create runtime script 40 | ARG PROJ_NAME="cfehome" 41 | # Set Python environment variables 42 | ENV PYTHONDONTWRITEBYTECODE=1 \ 43 | PYTHONUNBUFFERED=1 \ 44 | DJANGO_SETTINGS_MODULE=${PROJ_NAME}.settings 45 | 46 | WORKDIR /app 47 | COPY ./src /app 48 | 49 | # Add this line to copy gunicorn config 50 | COPY gunicorn.conf.py /app/ 51 | 52 | RUN printf "#!/bin/bash\n" > ./paracord_runner.sh && \ 53 | printf "RUN_PORT=\"\${PORT:-8080}\"\n\n" >> ./paracord_runner.sh && \ 54 | printf "python manage.py collectstatic --no-input\n" >> ./paracord_runner.sh && \ 55 | printf "python manage.py migrate --no-input\n" >> ./paracord_runner.sh && \ 56 | printf "gunicorn ${PROJ_NAME}.wsgi:application -c gunicorn.conf.py --bind \"[::]:\$RUN_PORT\"\n" >> ./paracord_runner.sh 57 | 58 | # Setup non-root user 59 | RUN groupadd -r paracord && useradd -r -g paracord paracord && \ 60 | chown -R paracord:paracord /app && \ 61 | chown -R paracord:paracord /opt/venv && \ 62 | chmod +x paracord_runner.sh && \ 63 | chown paracord:paracord paracord_runner.sh 64 | 65 | USER paracord 66 | 67 | CMD ./paracord_runner.sh -------------------------------------------------------------------------------- /backend/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | # Recommended: 2-4 workers per CPU core for Django 4 | cpu_count = multiprocessing.cpu_count() 5 | max_workers = 10 6 | workers = min(cpu_count * 2 + 1, max_workers) 7 | 8 | # Prevent stuck workers 9 | timeout = 60 10 | keepalive = 5 11 | 12 | # Graceful handling 13 | graceful_timeout = 30 14 | max_requests = 1000 15 | max_requests_jitter = 50 16 | 17 | # Logging 18 | loglevel = "info" # debug in prod is too verbose 19 | errorlog = "-" 20 | accesslog = "-" 21 | 22 | # Custom access log format that includes host header 23 | access_log_format = ( 24 | '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" host="%({Host}i)s"' 25 | ) 26 | 27 | # Performance 28 | worker_class = "sync" # Django works best with sync workers 29 | preload_app = True 30 | -------------------------------------------------------------------------------- /backend/railway.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "builder": "DOCKERFILE", 4 | "dockerfilePath": "./Dockerfile", 5 | "watchPatterns": [ 6 | "cfe.toml", 7 | "gunicorn.conf.py", 8 | "requirements.txt", 9 | "src/**", 10 | "railway.toml", 11 | "Dockerfile" 12 | ] 13 | }, 14 | "deploy": { 15 | "healthcheckPath": "/api/healthz/", 16 | "healthcheckTimeout": 300, 17 | "startupTimeout": 300, 18 | "restartPolicyType": "always", 19 | "restartPolicyMaxRetries": 10 20 | } 21 | } -------------------------------------------------------------------------------- /backend/requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.dev.txt src/requirements/dev.in 6 | # 7 | fire==0.7.0 8 | # via rav 9 | markdown-it-py==3.0.0 10 | # via rich 11 | mdurl==0.1.2 12 | # via markdown-it-py 13 | pygments==2.19.1 14 | # via rich 15 | pyyaml==6.0.2 16 | # via rav 17 | rav==0.0.9 18 | # via -r src/requirements/dev.in 19 | rich==13.9.4 20 | # via rav 21 | termcolor==2.5.0 22 | # via fire 23 | -------------------------------------------------------------------------------- /backend/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.txt src/requirements/prod.in 6 | # 7 | annotated-types==0.7.0 8 | # via pydantic 9 | asgiref==3.8.1 10 | # via 11 | # django 12 | # django-cors-headers 13 | # django-ninja-extra 14 | cachetools==5.5.2 15 | # via google-auth 16 | certifi==2025.1.31 17 | # via requests 18 | cffi==1.17.1 19 | # via cryptography 20 | charset-normalizer==3.4.1 21 | # via requests 22 | contextlib2==21.6.0 23 | # via django-ninja-extra 24 | cryptography==44.0.0 25 | # via 26 | # django-ninja-jwt 27 | # pyjwt 28 | dj-database-url==2.3.0 29 | # via -r src/requirements/prod.in 30 | django==5.1.5 31 | # via 32 | # -r src/requirements/prod.in 33 | # dj-database-url 34 | # django-cors-headers 35 | # django-ninja 36 | # django-ninja-extra 37 | # django-ninja-jwt 38 | django-cors-headers==4.6.0 39 | # via -r src/requirements/prod.in 40 | django-ninja==1.3.0 41 | # via 42 | # -r src/requirements/prod.in 43 | # django-ninja-extra 44 | django-ninja-extra==0.22.3 45 | # via django-ninja-jwt 46 | django-ninja-jwt[crypto]==5.3.5 47 | # via -r src/requirements/prod.in 48 | dnspython==2.7.0 49 | # via email-validator 50 | email-validator==2.2.0 51 | # via pydantic 52 | google-auth==2.38.0 53 | # via 54 | # -r src/requirements/prod.in 55 | # google-auth-httplib2 56 | # google-auth-oauthlib 57 | google-auth-httplib2==0.2.0 58 | # via -r src/requirements/prod.in 59 | google-auth-oauthlib==1.2.1 60 | # via -r src/requirements/prod.in 61 | httplib2==0.22.0 62 | # via google-auth-httplib2 63 | idna==3.10 64 | # via 65 | # email-validator 66 | # requests 67 | injector==0.22.0 68 | # via django-ninja-extra 69 | oauthlib==3.2.2 70 | # via requests-oauthlib 71 | psycopg[binary]==3.2.4 72 | # via -r src/requirements/prod.in 73 | psycopg-binary==3.2.4 74 | # via psycopg 75 | pyasn1==0.6.1 76 | # via 77 | # pyasn1-modules 78 | # rsa 79 | pyasn1-modules==0.4.1 80 | # via google-auth 81 | pycparser==2.22 82 | # via cffi 83 | pydantic[email]==2.10.6 84 | # via 85 | # -r src/requirements/prod.in 86 | # django-ninja 87 | pydantic-core==2.27.2 88 | # via pydantic 89 | pyjwt[crypto]==2.10.1 90 | # via django-ninja-jwt 91 | pyparsing==3.2.1 92 | # via httplib2 93 | python-decouple==3.8 94 | # via -r src/requirements/prod.in 95 | requests==2.32.3 96 | # via requests-oauthlib 97 | requests-oauthlib==2.0.0 98 | # via google-auth-oauthlib 99 | rsa==4.9 100 | # via google-auth 101 | sqlparse==0.5.3 102 | # via django 103 | typing-extensions==4.12.2 104 | # via 105 | # dj-database-url 106 | # psycopg 107 | # pydantic 108 | # pydantic-core 109 | urllib3==2.3.0 110 | # via requests 111 | -------------------------------------------------------------------------------- /backend/src/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/accounts/__init__.py -------------------------------------------------------------------------------- /backend/src/accounts/admin.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.contrib import admin 3 | from django.contrib.auth.models import Group 4 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 5 | from django.contrib.auth.forms import ReadOnlyPasswordHashField 6 | from django.core.exceptions import ValidationError 7 | 8 | from .models import MyUser 9 | 10 | 11 | class UserCreationForm(forms.ModelForm): 12 | """A form for creating new users. Includes all the required 13 | fields, plus a repeated password.""" 14 | 15 | password1 = forms.CharField(label="Password", widget=forms.PasswordInput) 16 | password2 = forms.CharField( 17 | label="Password confirmation", widget=forms.PasswordInput 18 | ) 19 | 20 | class Meta: 21 | model = MyUser 22 | fields = ["email", ] 23 | 24 | def clean_password2(self): 25 | # Check that the two password entries match 26 | password1 = self.cleaned_data.get("password1") 27 | password2 = self.cleaned_data.get("password2") 28 | if password1 and password2 and password1 != password2: 29 | raise ValidationError("Passwords don't match") 30 | return password2 31 | 32 | def save(self, commit=True): 33 | # Save the provided password in hashed format 34 | user = super().save(commit=False) 35 | user.set_password(self.cleaned_data["password1"]) 36 | if commit: 37 | user.save() 38 | return user 39 | 40 | 41 | class UserChangeForm(forms.ModelForm): 42 | """A form for updating users. Includes all the fields on 43 | the user, but replaces the password field with admin's 44 | disabled password hash display field. 45 | """ 46 | 47 | password = ReadOnlyPasswordHashField() 48 | 49 | class Meta: 50 | model = MyUser 51 | fields = ["email", "password", "is_active", "is_admin"] 52 | 53 | 54 | class UserAdmin(BaseUserAdmin): 55 | # The forms to add and change user instances 56 | form = UserChangeForm 57 | add_form = UserCreationForm 58 | 59 | # The fields to be used in displaying the User model. 60 | # These override the definitions on the base UserAdmin 61 | # that reference specific fields on auth.User. 62 | list_display = ["email", "is_admin"] 63 | list_filter = ["is_admin"] 64 | fieldsets = [ 65 | (None, {"fields": ["email", "password"]}), 66 | ("Personal info", {"fields": []}), 67 | ("Permissions", {"fields": ["is_active", "is_admin"]}), 68 | ] 69 | # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin 70 | # overrides get_fieldsets to use this attribute when creating a user. 71 | add_fieldsets = [ 72 | ( 73 | None, 74 | { 75 | "classes": ["wide"], 76 | "fields": ["email", "password1", "password2"], 77 | }, 78 | ), 79 | ] 80 | search_fields = ["email"] 81 | ordering = ["email"] 82 | filter_horizontal = [] 83 | 84 | 85 | # Now register the new UserAdmin... 86 | admin.site.register(MyUser, UserAdmin) 87 | # ... and, since we're not using Django's built-in permissions, 88 | # unregister the Group model from admin. 89 | admin.site.unregister(Group) -------------------------------------------------------------------------------- /backend/src/accounts/api.py: -------------------------------------------------------------------------------- 1 | import jwt # django-ninja 2 | from datetime import datetime, timezone 3 | from django.conf import settings 4 | from django.contrib.auth import get_user_model 5 | from helpers.api.auth.permissions import user_required 6 | 7 | 8 | from ninja import Router 9 | 10 | User = get_user_model() 11 | 12 | CKEDITOR_ENVIRONMENT_ID = "mOrAUyJPWgnlK37ARIKU" 13 | 14 | def get_user_token(user_id): 15 | try: 16 | user_instance = User.objects.get(id=user_id) 17 | except Exception: 18 | raise Exception("User failed") 19 | 20 | username = f"{user_instance.display_name}" 21 | iat = int(datetime.now(timezone.utc).timestamp()) 22 | algo = "HS256" 23 | headers = { 24 | "typ": "JWT", 25 | "alg": algo 26 | } 27 | payload = { 28 | "aud": f"{CKEDITOR_ENVIRONMENT_ID}", 29 | "iat": iat, 30 | "sub": username, 31 | "user": { 32 | "email": f"{user_instance.email}", 33 | "name": f"{user_instance.display_name}", 34 | }, 35 | "auth": { 36 | "collaboration": { 37 | "*": { 38 | "role": "writer" 39 | } 40 | } 41 | } 42 | } 43 | signed_token = jwt.encode( 44 | payload=payload, 45 | key=settings.CKEDITOR_ACCESS_CREDS, 46 | # key=settings.SECRET_KEY, 47 | algorithm=algo, 48 | headers=headers, 49 | ) 50 | return signed_token 51 | 52 | # users are required 53 | router = Router(auth=user_required) 54 | 55 | 56 | @router.get("/ckeditor/token/") 57 | def ckeditor_token_view(request): 58 | user = request.user 59 | token = get_user_token(user.id) 60 | return {"myUserToken": token} -------------------------------------------------------------------------------- /backend/src/accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'accounts' 7 | -------------------------------------------------------------------------------- /backend/src/accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-02-27 18:20 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='MyUser', 16 | fields=[ 17 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('password', models.CharField(max_length=128, verbose_name='password')), 19 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 20 | ('email', models.EmailField(max_length=255, unique=True, verbose_name='email address')), 21 | ('is_active', models.BooleanField(default=True)), 22 | ('is_admin', models.BooleanField(default=False)), 23 | ], 24 | options={ 25 | 'abstract': False, 26 | }, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /backend/src/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /backend/src/accounts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth.models import BaseUserManager, AbstractBaseUser 3 | 4 | 5 | class MyUserManager(BaseUserManager): 6 | def create_user(self, email, password=None, is_active:bool = True): 7 | """ 8 | Creates and saves a User with the given email and password. 9 | """ 10 | if not email: 11 | raise ValueError("Users must have an email address") 12 | 13 | user = self.model( 14 | email=self.normalize_email(email), 15 | ) 16 | 17 | user.set_password(password) 18 | user.is_active = is_active 19 | user.save(using=self._db) 20 | return user 21 | 22 | def create_superuser(self, email, password=None): 23 | """ 24 | Creates and saves a superuser with the given email and password. 25 | """ 26 | user = self.create_user( 27 | email, 28 | password=password, 29 | ) 30 | user.is_admin = True 31 | user.save(using=self._db) 32 | return user 33 | 34 | 35 | class MyUser(AbstractBaseUser): 36 | email = models.EmailField( 37 | verbose_name="email address", 38 | max_length=255, 39 | unique=True, 40 | ) 41 | is_active = models.BooleanField(default=True) 42 | is_admin = models.BooleanField(default=False) 43 | 44 | objects = MyUserManager() 45 | 46 | USERNAME_FIELD = "email" 47 | REQUIRED_FIELDS = [] 48 | 49 | def __str__(self): 50 | return self.email 51 | 52 | def has_perm(self, perm, obj=None): 53 | "Does the user have a specific permission?" 54 | # Simplest possible answer: Yes, always 55 | return True 56 | 57 | def has_module_perms(self, app_label): 58 | "Does the user have permissions to view the app `app_label`?" 59 | # Simplest possible answer: Yes, always 60 | return True 61 | 62 | @property 63 | def is_staff(self): 64 | "Is the user a member of staff?" 65 | # Simplest possible answer: All admins are staff 66 | return self.is_admin 67 | 68 | @property 69 | def display_name(self): 70 | email_user = self.email.split("@")[0] 71 | return f"{email_user}" -------------------------------------------------------------------------------- /backend/src/accounts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/src/accounts/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/src/ai/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/ai/__init__.py -------------------------------------------------------------------------------- /backend/src/ai/api.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Any 2 | from ninja import Schema, Router 3 | import random 4 | import time 5 | from helpers.api.auth.permissions import user_required 6 | 7 | router = Router(auth=user_required) 8 | 9 | class AIPayload(Schema): 10 | query: str 11 | context: Optional[Any] 12 | 13 | 14 | # /api/ai/ 15 | @router.post("/") 16 | def ai_echo_view(request, payload: AIPayload): 17 | # openai 18 | # claude 19 | # langchain 20 | # langgraph 21 | # pydantic ai 22 | # python -> llm 23 | sleep_for = 3.0 * ((random.randint(1,20) / 10)) 24 | time.sleep(sleep_for) 25 | print(payload.query, payload.context, type(payload.context)) 26 | return {"message": f"AI Says: {payload.query}"} -------------------------------------------------------------------------------- /backend/src/cfehome/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/cfehome/__init__.py -------------------------------------------------------------------------------- /backend/src/cfehome/api.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model, authenticate 2 | from helpers.api.auth.controllers import DjangoNextCustomController 3 | from helpers.api.auth.permissions import anon_required, user_or_anon 4 | from helpers.api.auth.schemas import ( 5 | UsernameMandatoryEmailMandatorySchema, 6 | EmailLoginSchema 7 | ) 8 | from helpers.api.users.schemas import UserSchema 9 | from ninja.errors import HttpError 10 | from ninja_extra import NinjaExtraAPI 11 | from ninja_extra.permissions import AllowAny 12 | from ninja_jwt.authentication import JWTAuth 13 | from ninja_jwt.controller import NinjaJWTDefaultController 14 | from ninja_jwt.tokens import RefreshToken 15 | 16 | from django.conf import settings 17 | from django.contrib.auth import login 18 | from django.http import HttpResponse 19 | from django.shortcuts import redirect, render 20 | 21 | from googler import ( 22 | oauth as googler_oauth, 23 | services as googler_services, 24 | schemas as googler_schemas 25 | ) 26 | 27 | from accounts.api import router as accounts_router 28 | from ai.api import router as ai_router 29 | from documents.api import router as document_router 30 | 31 | LOGIN_REDIRECT_URL = settings.LOGIN_REDIRECT_URL 32 | 33 | 34 | User = get_user_model() 35 | 36 | api = NinjaExtraAPI(auth=user_or_anon) 37 | 38 | # adds /api/token/refresh/ 39 | api.register_controllers(DjangoNextCustomController) 40 | api.add_router('/accounts', accounts_router) 41 | api.add_router('/ai', ai_router) 42 | api.add_router('/documents', document_router) 43 | 44 | 45 | @api.get("/hello/", auth=user_or_anon) 46 | def hello(request): 47 | print(request.auth, request.user) 48 | if request.auth: 49 | user = request.user 50 | if user.is_authenticated: 51 | return { 52 | "username": user.display_name, 53 | "email": user.email, 54 | } 55 | # print(request) 56 | return {"message": "Hello World"} 57 | 58 | 59 | @api.post("/login/", response=UserSchema, auth=anon_required) 60 | def login(request, payload: EmailLoginSchema): 61 | user = authenticate(email=payload.email, password=payload.password) 62 | if not user: 63 | raise HttpError(400, "Could not login user try again") 64 | if not user.is_active: 65 | raise HttpError(400, "User is not active") 66 | try: 67 | token = RefreshToken.for_user(user) 68 | return { 69 | "username": user.display_name, 70 | "email": user.email, 71 | "is_authenticated": True, 72 | "access_token": str(token.access_token), 73 | "refresh_token": str(token), 74 | } 75 | except Exception as e: 76 | raise HttpError(500, "Could not create user. Please try again later") 77 | 78 | 79 | @api.post("/signup/", response=UserSchema, auth=anon_required) 80 | def signup(request, payload: EmailLoginSchema): 81 | try: 82 | user = User.objects.create_user( 83 | email=payload.email, 84 | password=payload.password, 85 | is_active=True, 86 | ) 87 | user.save() 88 | token = RefreshToken.for_user(user) 89 | return { 90 | "username": user.display_name, 91 | "email": user.email, 92 | "is_authenticated": True, 93 | "access_token": str(token.access_token), 94 | "refresh_token": str(token), 95 | } 96 | except Exception as e: 97 | raise HttpError(500, "Could not create user. Please try again later") 98 | 99 | 100 | 101 | # /api/google/login/ 102 | @api.get("/google/login/", 103 | response=googler_schemas.GoogleLoginSchema, 104 | auth=anon_required) 105 | def google_login_view(request): 106 | google_oauth2_url = googler_oauth.generate_auth_url() 107 | return { 108 | "redirect_url": google_oauth2_url 109 | } 110 | 111 | @api.post("/google/callback/", 112 | response=UserSchema, 113 | auth=anon_required) 114 | def google_login_callback_view(request, payload: googler_schemas.GoogleCallbackSchema): 115 | # print(request.GET) 116 | state = payload.state 117 | code = payload.code 118 | try: 119 | token_json = googler_oauth.verify_google_oauth_callback(state, code) 120 | except Exception as e: 121 | raise HttpError(500, "Could login user. Please try again later") 122 | google_user_info = googler_oauth.verify_token_json(token_json) 123 | user = googler_services.get_or_create_google_user(google_user_info) 124 | if not user: 125 | raise HttpError(400, "Could not login user try again") 126 | if not user.is_active: 127 | raise HttpError(400, "User is not active") 128 | token = RefreshToken.for_user(user) 129 | return { 130 | "username": user.display_name, 131 | "email": user.email, 132 | "is_authenticated": True, 133 | "access_token": str(token.access_token), 134 | "refresh_token": str(token), 135 | } 136 | 137 | -------------------------------------------------------------------------------- /backend/src/cfehome/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for cfehome 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/5.1/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", "cfehome.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /backend/src/cfehome/installed.py: -------------------------------------------------------------------------------- 1 | # Application definition 2 | DJANGO_INSTALLED_APPS = [ 3 | "django.contrib.admin", 4 | "django.contrib.auth", 5 | "django.contrib.contenttypes", 6 | "django.contrib.sessions", 7 | "django.contrib.messages", 8 | "django.contrib.staticfiles", 9 | "django.contrib.sites", 10 | ] 11 | 12 | 13 | THIRD_PARTY_INSTALLED_APPS = [ 14 | "corsheaders", 15 | "ninja", 16 | "ninja_extra", 17 | "ninja_jwt", 18 | ] 19 | 20 | MY_APPS = [ 21 | "accounts", 22 | "documents", 23 | "profiles" 24 | ] 25 | 26 | 27 | INSTALLED_APPS = list(set(DJANGO_INSTALLED_APPS + THIRD_PARTY_INSTALLED_APPS + MY_APPS)) 28 | -------------------------------------------------------------------------------- /backend/src/cfehome/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for cfehome project using Django 5.1.5. 3 | """ 4 | 5 | from pathlib import Path 6 | from datetime import timedelta 7 | from django.core.management.utils import get_random_secret_key 8 | 9 | # uses python-decouple 10 | # loads environment variables from .env.local and .env 11 | from helpers import config 12 | 13 | from .installed import INSTALLED_APPS 14 | 15 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 16 | BASE_DIR = Path(__file__).resolve().parent.parent 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = config("DJANGO_SECRET_KEY", cast=str, default=get_random_secret_key()) 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = config("DJANGO_DEBUG", cast=bool, default=False) 27 | 28 | FRONTEND_URL = config("FRONTEND_URL", cast=str, default="https://djangonext.js") 29 | 30 | ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=list, default=[]) 31 | CSRF_TRUSTED_ORIGINS = config("DJANGO_CSRF_TRUSTED_ORIGINS", cast=list, default=[]) 32 | 33 | APPEND_SLASH = config("DJANGO_APPEND_SLASH", cast=bool, default=True) 34 | 35 | if DEBUG: 36 | ALLOWED_HOSTS = ["localhost", "127.0.0.1"] 37 | CSRF_TRUSTED_ORIGINS = [ 38 | "http://localhost:3000", 39 | "http://127.0.0.1:3000", 40 | "http://localhost:8000", 41 | "http://127.0.0.1:8000", 42 | ] 43 | 44 | # Application definition 45 | SITE_ID = 1 46 | INSTALLED_APPS = INSTALLED_APPS 47 | AUTH_USER_MODEL = "accounts.MyUser" # accounts.models.MyUser 48 | 49 | MIDDLEWARE = [ 50 | "django.middleware.security.SecurityMiddleware", 51 | "django.contrib.sessions.middleware.SessionMiddleware", 52 | "django.middleware.common.CommonMiddleware", 53 | "corsheaders.middleware.CorsMiddleware", 54 | "django.middleware.csrf.CsrfViewMiddleware", 55 | "django.contrib.auth.middleware.AuthenticationMiddleware", 56 | "django.contrib.messages.middleware.MessageMiddleware", 57 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 58 | ] 59 | 60 | ROOT_URLCONF = "cfehome.urls" 61 | 62 | TEMPLATES = [ 63 | { 64 | "BACKEND": "django.template.backends.django.DjangoTemplates", 65 | "DIRS": [BASE_DIR / "templates"], 66 | "APP_DIRS": True, 67 | "OPTIONS": { 68 | "context_processors": [ 69 | "django.template.context_processors.debug", 70 | "django.template.context_processors.request", 71 | "django.contrib.auth.context_processors.auth", 72 | "django.contrib.messages.context_processors.messages", 73 | ], 74 | }, 75 | }, 76 | ] 77 | 78 | WSGI_APPLICATION = "cfehome.wsgi.application" 79 | 80 | 81 | # Database 82 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases 83 | 84 | DATABASES = { 85 | "default": { 86 | "ENGINE": "django.db.backends.sqlite3", 87 | "NAME": BASE_DIR / "db.sqlite3", 88 | } 89 | } 90 | 91 | DATABASE_URL = config("DATABASE_URL", cast=str, default="") 92 | if DATABASE_URL: 93 | if DATABASE_URL.startswith("postgres://") or DATABASE_URL.startswith("postgresql://"): 94 | import dj_database_url 95 | 96 | DATABASES = { 97 | "default": dj_database_url.config( 98 | default=DATABASE_URL, 99 | conn_max_age=60, 100 | conn_health_checks=True, 101 | ) 102 | } 103 | else: 104 | raise Exception("DATABASE_URL only supports PostgreSQL at this time") 105 | 106 | # Password validation 107 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators 108 | 109 | AUTH_PASSWORD_VALIDATORS = [ 110 | { 111 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 112 | }, 113 | { 114 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 115 | }, 116 | { 117 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 118 | }, 119 | { 120 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 121 | }, 122 | ] 123 | 124 | 125 | # Internationalization 126 | # https://docs.djangoproject.com/en/5.1/topics/i18n/ 127 | 128 | LANGUAGE_CODE = "en-us" 129 | 130 | TIME_ZONE = "UTC" 131 | 132 | USE_I18N = True 133 | 134 | USE_TZ = True 135 | 136 | 137 | # Static files (CSS, JavaScript, Images) 138 | # https://docs.djangoproject.com/en/5.1/howto/static-files/ 139 | 140 | STATIC_URL = "static/" 141 | STATICFILES_BASE_DIR = BASE_DIR / "staticfiles" 142 | STATICFILES_DIRS = [STATICFILES_BASE_DIR] 143 | 144 | # Default primary key field type 145 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field 146 | 147 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 148 | 149 | 150 | 151 | ### GOOGLE API OAUTH LOGIN 152 | 153 | GOOGLE_CLIENT_ID = config("GOOGLE_CLIENT_ID", default="", cast=str) 154 | GOOGLE_SECRET_KEY = config("GOOGLE_SECRET_KEY", default="", cast=str) 155 | GOOGLE_AUTH_BASE_URL = FRONTEND_URL 156 | GOOGLE_AUTH_CALLBACK_PATH = config("GOOGLE_AUTH_CALLBACK_PATH", default='/google/callback') 157 | print(GOOGLE_AUTH_BASE_URL, GOOGLE_AUTH_CALLBACK_PATH) 158 | 159 | 160 | ##### NINJA JWT SETTINGS 161 | 162 | NINJA_JWT = { 163 | 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), 164 | 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 165 | } 166 | 167 | 168 | 169 | #### CKEDITOR SETTINGS 170 | CKEDITOR_ACCESS_CREDS = config("CKEDITOR_ACCESS_CREDS", default="", cast=str) -------------------------------------------------------------------------------- /backend/src/cfehome/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for cfehome project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.1/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | 18 | from django.contrib import admin 19 | from django.urls import path 20 | 21 | from . import views as base_views 22 | from .api import api 23 | 24 | urlpatterns = [ 25 | path("api/healthz/", base_views.healthz_view), 26 | path("api/", api.urls), 27 | path("admin/", admin.site.urls), 28 | ] 29 | -------------------------------------------------------------------------------- /backend/src/cfehome/views.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.http import JsonResponse 5 | from django.views.decorators.csrf import csrf_exempt 6 | 7 | 8 | @csrf_exempt 9 | def healthz_view(request): 10 | query_params = request.GET 11 | return JsonResponse( 12 | { 13 | "status": "ok", 14 | "frontend_url": settings.FRONTEND_URL, 15 | "query_params": dict(query_params), 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /backend/src/cfehome/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for cfehome 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/5.1/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", "cfehome.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/src/documents/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/documents/__init__.py -------------------------------------------------------------------------------- /backend/src/documents/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import Doc, DocUser 5 | 6 | 7 | class DocUserInline(admin.TabularInline): 8 | model = DocUser 9 | extra = 0 10 | 11 | class DocAdmin(admin.ModelAdmin): 12 | inlines = [DocUserInline] 13 | 14 | 15 | admin.site.register(Doc, DocAdmin) -------------------------------------------------------------------------------- /backend/src/documents/api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from ninja import Router 3 | from ninja.errors import HttpError 4 | 5 | from helpers.api.auth.permissions import user_required 6 | 7 | from .models import Doc 8 | from .schemas import DocSchema, DocUpdateSchema, DocCreateSchema 9 | from . import exceptions as doc_exceptions 10 | from . import services as doc_services 11 | 12 | router = Router() 13 | 14 | 15 | @router.get("/", response=List[DocSchema], auth=user_required) 16 | def document_list_view(request): 17 | qs = doc_services.list_documents(request.user) 18 | return qs 19 | 20 | @router.post("/", response={201: DocSchema}, auth=user_required) 21 | def document_create_view(request, payload:DocCreateSchema): 22 | obj = doc_services.create_document(user=request.user, title=payload.title) 23 | if obj is None: 24 | raise HttpError(400, "Invalid data, try again.") 25 | return 201, obj 26 | 27 | def http_document_detail(request, document_id): 28 | try: 29 | obj = doc_services.get_document(user=request.user, document_id=document_id) 30 | except doc_exceptions.DocumentNotFound as e: 31 | raise HttpError(404, f"{e}") 32 | except doc_exceptions.UserNoPermissionNotAllowed as e: 33 | raise HttpError(403, f"{e}") 34 | except: 35 | raise HttpError(500, "Unknown server error") 36 | if obj is None: 37 | raise HttpError(404, f"{document_id} is not found") 38 | return obj 39 | 40 | @router.get("/{document_id}/", response=DocSchema, auth=user_required) 41 | def document_detail_view(request, document_id): 42 | obj = http_document_detail(request, document_id) 43 | return obj 44 | 45 | 46 | @router.put("/{document_id}/", response=DocSchema, auth=user_required) 47 | def document_update_view(request, document_id, payload:DocUpdateSchema): 48 | obj = http_document_detail(request, document_id) 49 | update_data = payload.model_dump() 50 | for key, val in update_data.items(): 51 | setattr(obj, key, val) 52 | # obj.last_updated_by = request.user 53 | obj.save() 54 | return obj 55 | 56 | 57 | """ 58 | @router.delete("/{document_id}/", response={204: Any}, auth=user_required) 59 | def document_update_view(request, document_id, payload:DocUpdateSchema): 60 | obj = http_document_detail(request, document_id) 61 | return 204, "Item delete" 62 | """ -------------------------------------------------------------------------------- /backend/src/documents/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DocumentsConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'documents' 7 | -------------------------------------------------------------------------------- /backend/src/documents/exceptions.py: -------------------------------------------------------------------------------- 1 | class DocumentNotFound(Exception): 2 | pass 3 | 4 | class UserNoPermissionNotAllowed(Exception): 5 | pass -------------------------------------------------------------------------------- /backend/src/documents/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-02-28 21:09 2 | 3 | import django.db.models.deletion 4 | import uuid 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Doc', 20 | fields=[ 21 | ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 22 | ('title', models.CharField(blank=True, max_length=120, null=True)), 23 | ('content', models.TextField(blank=True, null=True)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('updated_at', models.DateTimeField(auto_now=True)), 26 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /backend/src/documents/migrations/0002_docuser.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-03-06 00:01 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('documents', '0001_initial'), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='DocUser', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('active', models.BooleanField(default=True)), 21 | ('inactive_at', models.DateTimeField(blank=True, null=True)), 22 | ('created_at', models.DateTimeField(auto_now_add=True)), 23 | ('updated_at', models.DateTimeField(auto_now=True)), 24 | ('doc', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='documents.doc')), 25 | ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /backend/src/documents/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/documents/migrations/__init__.py -------------------------------------------------------------------------------- /backend/src/documents/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.conf import settings 3 | from django.db import models 4 | from django.utils import timezone 5 | 6 | # Create your models here. 7 | 8 | User = settings.AUTH_USER_MODEL 9 | 10 | class Doc(models.Model): 11 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, db_index=True, editable=False) 12 | user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) 13 | title = models.CharField(max_length=120, blank=True, null=True) 14 | content = models.TextField(blank=True, null=True) 15 | created_at = models.DateTimeField(auto_now_add=True) 16 | updated_at = models.DateTimeField(auto_now=True) 17 | 18 | 19 | class DocUser(models.Model): 20 | doc = models.ForeignKey(Doc, null=True, on_delete=models.SET_NULL) 21 | user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) 22 | active = models.BooleanField(default=True) 23 | inactive_at = models.DateTimeField(auto_now=False, auto_now_add=False, null=True, blank=True) 24 | created_at = models.DateTimeField(auto_now_add=True) 25 | updated_at = models.DateTimeField(auto_now=True) 26 | 27 | def save(self, *args, **kwargs): 28 | if not self.active and self.inactive_at is None: 29 | self.inactive_at = timezone.now() 30 | super().save(*args, **kwargs) -------------------------------------------------------------------------------- /backend/src/documents/schemas.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from ninja import Schema, Field 3 | 4 | # Schema -> Pydantic BaseModel 5 | 6 | class DocSchema(Schema): 7 | id: uuid.UUID 8 | title: str 9 | content: str | None = Field(default="") 10 | 11 | 12 | class DocCreateSchema(Schema): 13 | title: str 14 | 15 | class DocUpdateSchema(Schema): 16 | title: str 17 | content: str -------------------------------------------------------------------------------- /backend/src/documents/services.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.core.cache import cache 3 | 4 | from . import exceptions 5 | from .models import Doc 6 | 7 | DOC_CACHE_KEY = "documents:list:{user_id}" 8 | DOC_CACHE_TIMEOUT = 300 9 | 10 | 11 | def create_document(user=None, title=None): 12 | if user is None or title is None: 13 | return None 14 | return Doc.objects.create(user=user, title=title) 15 | 16 | 17 | def list_documents(user=None, force=False): 18 | if user is None: 19 | return [] 20 | cache_key = DOC_CACHE_KEY.format(user_id=user.id) 21 | cached_qs = cache.get(cache_key) 22 | if cached_qs and not force: 23 | return cached_qs 24 | qs = Doc.objects.filter( 25 | Q(user=user) | 26 | Q(docuser__user=user) 27 | ).values('id', 'content', 'title') 28 | cache.set(cache_key, qs, timeout=DOC_CACHE_TIMEOUT) 29 | return qs 30 | 31 | def get_document(user=None, document_id=None): 32 | if user is None or document_id is None: 33 | return None 34 | try: 35 | obj = Doc.objects.get(id=document_id) 36 | except Doc.DoesNotExist: 37 | raise exceptions.DocumentNotFound(f"{document_id} not found.") 38 | except: 39 | raise exceptions.DocumentNotFound(f"{document_id} not found.") 40 | is_owner = obj.user == user 41 | is_doc_user = obj.docuser_set.filter(user=user, active=True).exists() 42 | has_permission = is_owner or is_doc_user 43 | if not has_permission: 44 | raise exceptions.UserNoPermissionNotAllowed(f"{user} needs access.") 45 | return obj -------------------------------------------------------------------------------- /backend/src/documents/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/src/documents/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/src/googler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/googler/__init__.py -------------------------------------------------------------------------------- /backend/src/googler/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /backend/src/googler/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GooglerConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'googler' 7 | -------------------------------------------------------------------------------- /backend/src/googler/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/googler/migrations/__init__.py -------------------------------------------------------------------------------- /backend/src/googler/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /backend/src/googler/oauth.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urljoin, urlencode 2 | from django.conf import settings 3 | from django.core.cache import cache 4 | from google.auth.transport import requests as google_requests 5 | from google.oauth2 import id_token 6 | from django.urls import reverse 7 | 8 | import requests 9 | 10 | from . import security 11 | 12 | GOOGLE_AUTH_CACHE_KEY_PREFIX = "google:auth:state" 13 | GOOGLE_CLIENT_ID = settings.GOOGLE_CLIENT_ID 14 | GOOGLE_SECRET_KEY = settings.GOOGLE_SECRET_KEY 15 | GOOGLE_AUTH_BASE_URL = settings.GOOGLE_AUTH_BASE_URL 16 | 17 | def get_google_auth_callback_path(): 18 | return reverse("googler:callback") 19 | 20 | 21 | def get_google_oauth_callback_url(drop_https=False, force_https=False): 22 | callback_path = getattr(settings, 'GOOGLE_AUTH_CALLBACK_PATH', None) or get_google_auth_callback_path() 23 | url = urljoin(GOOGLE_AUTH_BASE_URL, callback_path) 24 | if drop_https: 25 | url = url.replace("https://", "http://") 26 | if force_https: 27 | url = url.replace("http://", "https://") 28 | return url 29 | 30 | 31 | def generate_auth_url(): 32 | redirect_uri = get_google_oauth_callback_url() 33 | # public state item 34 | state = security.generate_state() 35 | 36 | # private, public 37 | code_verifier, code_challenge = security.generate_pkce_pair() 38 | # request.session['some_val'] = code_verifier 39 | 40 | cache_key = f"{GOOGLE_AUTH_CACHE_KEY_PREFIX}:{state}" 41 | # use redis caching key-val 42 | cache.set(cache_key, code_verifier, 30) 43 | # cache.get(cache_key) 44 | 45 | # google cloud auth platform client id 46 | google_auth_client_id = GOOGLE_CLIENT_ID 47 | 48 | scope = " ".join([ 49 | "openid", 50 | "email", 51 | "profile", 52 | ]) 53 | 54 | auth_params = { 55 | "client_id": google_auth_client_id, 56 | "redirect_uri": redirect_uri, 57 | "response_type": "code", 58 | "scope": scope, 59 | "state": state, 60 | "code_challenge": code_challenge, 61 | "code_challenge_method": "S256", 62 | "access_type": "offline", 63 | # "prompt": "consent", 64 | "prompt": "select_account", 65 | } 66 | encoded_params = urlencode(auth_params) 67 | google_oauth_url = "https://accounts.google.com/o/oauth2/v2/auth" 68 | return urljoin(google_oauth_url, f"?{encoded_params}") 69 | 70 | 71 | def verify_google_oauth_callback(state, code): 72 | redirect_uri = get_google_oauth_callback_url() 73 | cache_key = f"{GOOGLE_AUTH_CACHE_KEY_PREFIX}:{state}" 74 | code_verifier = cache.get(cache_key) 75 | if code_verifier is None or code is None or state is None: 76 | raise Exception("Invalid code or expired code.") 77 | 78 | token_endpoint = "https://oauth2.googleapis.com/token" 79 | token_data = { 80 | "client_id": GOOGLE_CLIENT_ID, 81 | "client_secret": GOOGLE_SECRET_KEY, 82 | "redirect_uri": redirect_uri, 83 | "grant_type": "authorization_code", 84 | "code": code, 85 | "code_verifier": code_verifier, 86 | } 87 | r = requests.post(token_endpoint, data=token_data) 88 | r.raise_for_status() 89 | return r.json() 90 | 91 | 92 | def verify_token_json(token_json): 93 | id_token_jwt = token_json.get('id_token') 94 | google_user_info = id_token.verify_oauth2_token( 95 | id_token_jwt, google_requests.Request(), GOOGLE_CLIENT_ID 96 | ) 97 | if google_user_info["iss"] not in [ 98 | "accounts.google.com", 99 | "https://accounts.google.com", 100 | ]: 101 | raise Exception("Invalid issuer") 102 | return google_user_info -------------------------------------------------------------------------------- /backend/src/googler/schemas.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class GoogleLoginSchema(Schema): 5 | redirect_url: str 6 | 7 | 8 | class GoogleCallbackSchema(Schema): 9 | code: str 10 | state: str -------------------------------------------------------------------------------- /backend/src/googler/security.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import os 4 | 5 | def generate_state(): 6 | state = base64.urlsafe_b64encode(os.urandom(32)).decode("utf-8").replace("=", "") 7 | return state 8 | 9 | def generate_pkce_pair(): 10 | """Generate PKCE code verifier and challenge pair.""" 11 | code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8") 12 | code_verifier = code_verifier.replace("=", "") # Remove padding 13 | 14 | code_challenge = hashlib.sha256(code_verifier.encode("utf-8")).digest() 15 | code_challenge = base64.urlsafe_b64encode(code_challenge).decode("utf-8") 16 | code_challenge = code_challenge.replace("=", "") # Remove padding 17 | 18 | return code_verifier, code_challenge 19 | -------------------------------------------------------------------------------- /backend/src/googler/services.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.contrib.auth import get_user_model 3 | 4 | User = get_user_model() 5 | 6 | def get_or_create_google_user(google_user_info): 7 | email = google_user_info.get("email") 8 | if not email: 9 | raise ValueError("No email provided by Google") 10 | 11 | try: 12 | user = User.objects.get(email=email) 13 | except User.DoesNotExist: 14 | # Create new user 15 | user = User.objects.create_user( 16 | email=email, 17 | # first_name=google_user_info.get("given_name", ""), 18 | # last_name=google_user_info.get("family_name", ""), 19 | ) 20 | user.set_unusable_password() 21 | # You might want to mark this user as having been created through Google OAuth 22 | user.save() 23 | 24 | return user -------------------------------------------------------------------------------- /backend/src/googler/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/src/googler/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from googler import views 4 | 5 | app_name = 'googler' 6 | urlpatterns = [ 7 | path('login/', views.google_login_view, name='login'), 8 | path('callback/', views.google_login_callback_view, name='callback'), 9 | ] 10 | -------------------------------------------------------------------------------- /backend/src/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .dotenv.loader import config 2 | 3 | __all__ = ["config"] 4 | -------------------------------------------------------------------------------- /backend/src/helpers/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/helpers/api/__init__.py -------------------------------------------------------------------------------- /backend/src/helpers/api/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/helpers/api/auth/__init__.py -------------------------------------------------------------------------------- /backend/src/helpers/api/auth/controllers.py: -------------------------------------------------------------------------------- 1 | from ninja_extra import api_controller, http_post 2 | from ninja_jwt.controller import TokenObtainPairController 3 | from ninja_jwt.schema_control import SchemaControl 4 | from ninja_jwt.settings import api_settings 5 | 6 | schema = SchemaControl(api_settings) 7 | 8 | 9 | class DjangoNextTokenObtainPairController(TokenObtainPairController): 10 | @http_post( 11 | "/pair/", 12 | response=schema.obtain_pair_schema.get_response_schema(), 13 | url_name="token_obtain_pair_slash", 14 | operation_id="token_obtain_pair", 15 | ) 16 | def obtain_token(self, user_token: schema.obtain_pair_schema): 17 | user_token.check_user_authentication_rule() 18 | return user_token.to_response_schema() 19 | 20 | @http_post( 21 | "/refresh/", 22 | response=schema.obtain_pair_refresh_schema.get_response_schema(), 23 | url_name="token_refresh_slash", 24 | operation_id="token_refresh", 25 | ) 26 | def refresh_token(self, refresh_token: schema.obtain_pair_refresh_schema): 27 | return refresh_token.to_response_schema() 28 | 29 | 30 | @api_controller("token", tags=["Auth"]) 31 | class DjangoNextCustomController(DjangoNextTokenObtainPairController): 32 | """obtain token and refresh_token with support for trailing slashes""" 33 | 34 | pass 35 | pass 36 | -------------------------------------------------------------------------------- /backend/src/helpers/api/auth/permissions.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | from ninja_jwt.authentication import JWTAuth 3 | 4 | 5 | def allow_anon(request: HttpRequest) -> bool: 6 | """Check if user is anonymous (not authenticated)""" 7 | return not request.user.is_authenticated 8 | 9 | 10 | def verify_authenticated(request: HttpRequest) -> bool: 11 | """Check if user is authenticated""" 12 | return request.user.is_authenticated 13 | 14 | 15 | def allow_staff(request: HttpRequest) -> bool: 16 | """Check if user is staff member""" 17 | return request.user.is_authenticated and request.user.is_staff 18 | 19 | 20 | def allow_superuser(request: HttpRequest) -> bool: 21 | """Check if user is superuser""" 22 | return request.user.is_authenticated and request.user.is_superuser 23 | 24 | 25 | def allow_any(request: HttpRequest) -> bool: 26 | """Allow both authenticated and anonymous users""" 27 | return True 28 | 29 | 30 | # Common permission combinations 31 | user_required = [JWTAuth(), verify_authenticated] 32 | anon_required = [JWTAuth(), allow_anon] 33 | staff_required = [JWTAuth(), allow_staff] 34 | superuser_required = [JWTAuth(), allow_superuser] 35 | user_or_anon = [JWTAuth(), allow_any] # Allows both authenticated and anonymous users 36 | -------------------------------------------------------------------------------- /backend/src/helpers/api/auth/schemas.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.core.validators import EmailValidator 5 | from ninja import Schema 6 | from ninja.errors import HttpError 7 | 8 | 9 | class BaseUserSchema(Schema): 10 | """Base schema with common validation methods""" 11 | 12 | @staticmethod 13 | def validate_username(value: str | None) -> str | None: 14 | if value is None: 15 | return None 16 | if len(value) < 3: 17 | raise HttpError(400, "Username must be at least 3 characters long") 18 | if not re.match("^[a-zA-Z0-9_]+$", value): 19 | raise HttpError( 20 | 400, "Username can only contain letters, numbers, and underscores" 21 | ) 22 | return value 23 | 24 | @staticmethod 25 | def validate_email(value: str | None) -> str | None: 26 | if value is None: 27 | return None 28 | email_validator = EmailValidator() 29 | try: 30 | email_validator(value) 31 | except: 32 | raise HttpError(400, "Invalid email address") 33 | return value 34 | 35 | @staticmethod 36 | def validate_password(value: str) -> str: 37 | if len(value) < 8: 38 | raise HttpError(400, "Password must be at least 8 characters long") 39 | if not any(c.isupper() for c in value): 40 | raise HttpError(400, "Password must contain at least one uppercase letter") 41 | if not any(c.islower() for c in value): 42 | raise HttpError(400, "Password must contain at least one lowercase letter") 43 | if not any(c.isdigit() for c in value): 44 | raise HttpError(400, "Password must contain at least one number") 45 | return value 46 | 47 | def check_availability(self, username: str | None, email: str | None) -> None: 48 | User = get_user_model() 49 | conditions = [] 50 | 51 | if username: 52 | conditions.append(User.objects.filter(username__iexact=username).exists()) 53 | if email: 54 | conditions.append(User.objects.filter(email__iexact=email).exists()) 55 | 56 | if any(conditions): 57 | raise HttpError( 58 | 400, "Username and/or email are not available. Please try again" 59 | ) 60 | 61 | 62 | class BaseRegistrationSchema(BaseUserSchema): 63 | """Base registration schema with common password validation""" 64 | 65 | password: str 66 | confirm_password: str 67 | 68 | def validate(self, data: dict) -> dict: 69 | if data["password"] != data["confirm_password"]: 70 | raise HttpError(400, "Passwords do not match") 71 | self.check_availability(username=data.get("username"), email=data.get("email")) 72 | return data 73 | 74 | 75 | class UsernameOnlySchema(BaseRegistrationSchema): 76 | username: str 77 | 78 | 79 | class EmailOnlySchema(BaseRegistrationSchema): 80 | email: str 81 | 82 | 83 | class UsernameMandatoryEmailOptionalSchema(BaseRegistrationSchema): 84 | username: str 85 | email: str | None = None 86 | 87 | 88 | class EmailMandatoryUsernameOptionalSchema(BaseRegistrationSchema): 89 | username: str | None = None 90 | email: str 91 | 92 | 93 | class UsernameMandatoryEmailMandatorySchema(BaseRegistrationSchema): 94 | username: str 95 | email: str 96 | 97 | 98 | 99 | class EmailLoginSchema(BaseUserSchema): 100 | email: str 101 | password: str -------------------------------------------------------------------------------- /backend/src/helpers/api/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/helpers/api/users/__init__.py -------------------------------------------------------------------------------- /backend/src/helpers/api/users/schemas.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class UserSchema(Schema): 5 | username: str | None 6 | email: str 7 | is_authenticated: bool 8 | access_token: str | None 9 | refresh_token: str | None 10 | -------------------------------------------------------------------------------- /backend/src/helpers/dotenv/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/helpers/dotenv/__init__.py -------------------------------------------------------------------------------- /backend/src/helpers/dotenv/loader.py: -------------------------------------------------------------------------------- 1 | # using python-decouple to load environment variables 2 | import logging 3 | import pathlib 4 | from functools import lru_cache 5 | 6 | from decouple import Config, RepositoryEnv 7 | 8 | THIS_DIR = pathlib.Path(__file__).parent 9 | HELPERS_DIR = THIS_DIR.parent # src/helpers/ 10 | BASE_DIR = HELPERS_DIR.parent # src/ 11 | PROJECT_DIR = BASE_DIR.parent # / 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @lru_cache(maxsize=1) 17 | def get_config(allowed_envs: list[str] = [".env", ".env.local"]): 18 | for env in allowed_envs: 19 | path = PROJECT_DIR / env 20 | if path.exists(): 21 | try: 22 | logger.info(f"Loading {env}") 23 | return Config(RepositoryEnv(path)) 24 | except FileNotFoundError: 25 | continue 26 | from decouple import config 27 | 28 | return config 29 | 30 | 31 | config = get_config() 32 | -------------------------------------------------------------------------------- /backend/src/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 | """Run administrative tasks.""" 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cfehome.settings") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /backend/src/profiles/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/profiles/__init__.py -------------------------------------------------------------------------------- /backend/src/profiles/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import Profile 5 | 6 | admin.site.register(Profile) -------------------------------------------------------------------------------- /backend/src/profiles/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ProfilesConfig(AppConfig): 5 | default_auto_field = 'django.db.models.BigAutoField' 6 | name = 'profiles' 7 | 8 | 9 | def ready(self): 10 | import profiles.signals # noqa 11 | -------------------------------------------------------------------------------- /backend/src/profiles/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-02-27 17:54 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Profile', 19 | fields=[ 20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('bio', models.TextField(blank=True, null=True)), 22 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /backend/src/profiles/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/profiles/migrations/__init__.py -------------------------------------------------------------------------------- /backend/src/profiles/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | User = settings.AUTH_USER_MODEL # "auth.User" 5 | 6 | class Profile(models.Model): 7 | user = models.OneToOneField(User, on_delete=models.CASCADE) 8 | bio = models.TextField(blank=True, null=True) -------------------------------------------------------------------------------- /backend/src/profiles/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | 4 | from django.conf import settings 5 | from .models import Profile 6 | 7 | User = settings.AUTH_USER_MODEL # "auth.User" 8 | 9 | 10 | @receiver(post_save, sender=User) 11 | def create_user_profile_post_save(sender, instance, created, *args, **kwargs): 12 | if created: 13 | Profile.objects.get_or_create(user=instance) -------------------------------------------------------------------------------- /backend/src/profiles/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /backend/src/profiles/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /backend/src/requirements/dev.in: -------------------------------------------------------------------------------- 1 | rav -------------------------------------------------------------------------------- /backend/src/requirements/prod.in: -------------------------------------------------------------------------------- 1 | django 2 | django-cors-headers 3 | django-ninja 4 | django-ninja-jwt[crypto] 5 | pydantic[email] 6 | python-decouple 7 | dj-database-url 8 | psycopg[binary] 9 | google-auth 10 | google-auth-oauthlib 11 | google-auth-httplib2 -------------------------------------------------------------------------------- /backend/src/staticfiles/.git-keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/staticfiles/.git-keep -------------------------------------------------------------------------------- /backend/src/templates/.git-keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/backend/src/templates/.git-keep -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:17 4 | environment: 5 | POSTGRES_USER: postgres 6 | POSTGRES_PASSWORD: postgres 7 | POSTGRES_DB: postgres 8 | ports: 9 | - "5430:5432" 10 | volumes: 11 | - postgres_data:/var/lib/postgresql/data 12 | 13 | volumes: 14 | postgres_data: 15 | -------------------------------------------------------------------------------- /frontend/.env.sample: -------------------------------------------------------------------------------- 1 | DJANGO_API_URL=http://localhost:8000/api/ -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .env* 133 | !.env.sample 134 | .venv 135 | env/ 136 | venv/ 137 | ENV/ 138 | env.bak/ 139 | venv.bak/ 140 | 141 | # Spyder project settings 142 | .spyderproject 143 | .spyproject 144 | 145 | # Rope project settings 146 | .ropeproject 147 | 148 | # mkdocs documentation 149 | /site 150 | 151 | # mypy 152 | .mypy_cache/ 153 | .dmypy.json 154 | dmypy.json 155 | 156 | # Pyre type checker 157 | .pyre/ 158 | 159 | # pytype static type analyzer 160 | .pytype/ 161 | 162 | # Cython debug symbols 163 | cython_debug/ 164 | 165 | # PyCharm 166 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 167 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 168 | # and can be added to the global gitignore or merged into this file. For a more nuclear 169 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 170 | #.idea/ 171 | 172 | # Ruff stuff: 173 | .ruff_cache/ 174 | 175 | # PyPI configuration file 176 | .pypirc 177 | 178 | 179 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 180 | 181 | # dependencies 182 | frontend/.next 183 | frontend/node_modules 184 | /node_modules 185 | /.pnp 186 | .pnp.* 187 | .yarn/* 188 | !.yarn/patches 189 | !.yarn/plugins 190 | !.yarn/releases 191 | !.yarn/versions 192 | 193 | # testing 194 | /coverage 195 | 196 | # next.js 197 | /.next/ 198 | /out/ 199 | 200 | # production 201 | /build 202 | 203 | # misc 204 | .DS_Store 205 | *.pem 206 | 207 | # debug 208 | npm-debug.log* 209 | yarn-debug.log* 210 | yarn-error.log* 211 | .pnpm-debug.log* 212 | 213 | 214 | # vercel 215 | .vercel 216 | 217 | # typescript 218 | *.tsbuildinfo 219 | next-env.d.ts 220 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Node.js 22 as the base image 2 | FROM node:22.6.0-alpine 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Install curl 8 | RUN apk add --no-cache curl 9 | 10 | # Copy package.json and package-lock.json to the working directory 11 | COPY package*.json ./ 12 | 13 | # Install dependencies 14 | RUN npm install 15 | 16 | # Copy the rest of the application code 17 | COPY . . 18 | 19 | # Build the Next.js application 20 | RUN npm run build 21 | 22 | # Use an environment variable for the port, defaulting to 3000 23 | ENV PORT=3000 24 | 25 | # Expose the port that Next.js runs on 26 | EXPOSE ${PORT} 27 | 28 | # Start the application, passing the PORT environment variable 29 | CMD ["sh", "-c", "npm start -- -p ${PORT}"] -------------------------------------------------------------------------------- /frontend/Dockerfile.demo: -------------------------------------------------------------------------------- 1 | # Used for Railway demo with this monorepo 2 | 3 | # Use Node.js 22 as the base image 4 | FROM node:22.6.0-alpine 5 | 6 | # Set the working directory in the container 7 | WORKDIR /app 8 | 9 | # Install curl 10 | RUN apk add --no-cache curl 11 | 12 | # Copy files conditionally based on directory structure 13 | # mostly for Railway demo 14 | COPY . /tmp/source/ 15 | RUN if [ -d "/tmp/source/frontend" ]; then \ 16 | echo "Using /tmp/source/frontend directory" && \ 17 | cp -r /tmp/source/frontend/* .; \ 18 | else \ 19 | echo "Using /tmp/source directory" && \ 20 | cp -r /tmp/source/* .; \ 21 | fi && \ 22 | rm -rf /tmp/source 23 | 24 | RUN cat package.json 25 | 26 | # Install dependencies 27 | RUN npm install 28 | 29 | # Build the Next.js application 30 | RUN npm run build 31 | 32 | # Use an environment variable for the port, defaulting to 3000 33 | ENV PORT=3000 34 | 35 | # Expose the port that Next.js runs on 36 | EXPOSE ${PORT} 37 | 38 | # Start the application, passing the PORT environment variable 39 | CMD ["sh", "-c", "npm start -- -p ${PORT}"] -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": false, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /frontend/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [...compat.extends("next/core-web-vitals")]; 13 | 14 | export default eslintConfig; 15 | -------------------------------------------------------------------------------- /frontend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "rm -rf .next && next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@ckeditor/ckeditor5-react": "^9.5.0", 13 | "@radix-ui/react-dialog": "^1.1.5", 14 | "@radix-ui/react-dropdown-menu": "^2.1.5", 15 | "@radix-ui/react-icons": "^1.3.2", 16 | "@radix-ui/react-label": "^2.1.1", 17 | "@radix-ui/react-slot": "^1.1.1", 18 | "@tailwindcss/typography": "^0.5.16", 19 | "ckeditor5": "^44.2.1", 20 | "ckeditor5-premium-features": "^44.3.0", 21 | "class-variance-authority": "^0.7.1", 22 | "clsx": "^2.1.1", 23 | "lucide-react": "^0.474.0", 24 | "next": "15.1.6", 25 | "next-themes": "^0.4.4", 26 | "react": "^19.0.0", 27 | "react-dom": "^19.0.0", 28 | "swr": "^2.3.0", 29 | "tailwind-merge": "^2.6.0", 30 | "tailwindcss-animate": "^1.0.7" 31 | }, 32 | "devDependencies": { 33 | "@eslint/eslintrc": "^3.2.0", 34 | "eslint": "^9.19.0", 35 | "eslint-config-next": "15.1.6", 36 | "postcss": "^8.5.1", 37 | "tailwindcss": "^3.4.17" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /frontend/public/django-nextjs-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/frontend/public/django-nextjs-favicon.png -------------------------------------------------------------------------------- /frontend/public/django-nextjs-text.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/django-nextjs.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /frontend/public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/frontend/public/login.png -------------------------------------------------------------------------------- /frontend/public/signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/frontend/public/signup.png -------------------------------------------------------------------------------- /frontend/public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/railway.demo.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "builder": "DOCKERFILE", 4 | "dockerfilePath": "/frontend/Dockerfile.demo" 5 | }, 6 | "deploy": { 7 | "healthcheckPath": "/healthz" 8 | } 9 | } -------------------------------------------------------------------------------- /frontend/railway.json: -------------------------------------------------------------------------------- 1 | { 2 | "build": { 3 | "builder": "DOCKERFILE", 4 | "dockerfilePath": "./Dockerfile", 5 | "watchPatterns": [ 6 | "public/**", 7 | "src/**", 8 | "Dockerfile", 9 | "jsconfig.json", 10 | "next.config.js", 11 | "package-lock.json", 12 | "package.json", 13 | "postcss.config.js", 14 | "railway.toml", 15 | "tailwind.config.js" 16 | ] 17 | }, 18 | "deploy": { 19 | "healthcheckPath": "/healthz" 20 | } 21 | } -------------------------------------------------------------------------------- /frontend/src/app/api/[...path]/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import {urlJoin} from '@/lib/urlJoin'; 3 | import { TokenFetch } from '@/lib/tokenFetcher'; 4 | 5 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 6 | 7 | export async function GET(request, { params }) { 8 | const path = (await params).path; 9 | const url = urlJoin(DJANGO_API_URL, path) + request.nextUrl.search; 10 | try { 11 | const response = await TokenFetch.fetch(url, { 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | method: 'GET', 16 | }); 17 | if (!response.ok) { 18 | return NextResponse.json( 19 | { error: 'Failed to fetch from backend' }, 20 | { status: response.status } 21 | ); 22 | } 23 | const data = await response.json(); 24 | return NextResponse.json(data, { status: response.status }); 25 | } catch (error) { 26 | return NextResponse.json( 27 | { error: 'Failed to fetch from backend' }, 28 | { status: 500 } 29 | ); 30 | } 31 | } 32 | 33 | export async function POST(request, { params }) { 34 | const path = (await params).path; 35 | const url = urlJoin(DJANGO_API_URL, path) + request.nextUrl.search; 36 | try { 37 | const body = await request.json(); 38 | const response = await TokenFetch.fetch(url, { 39 | method: 'POST', 40 | headers: { 41 | 'Content-Type': 'application/json', 42 | // Forward other headers as needed 43 | }, 44 | body: JSON.stringify(body), 45 | }); 46 | if (!response.ok) { 47 | return NextResponse.json( 48 | { error: 'Failed to fetch from backend' }, 49 | { status: response.status } 50 | ); 51 | } 52 | 53 | const data = await response.json(); 54 | return NextResponse.json(data, { status: response.status }); 55 | } catch (error) { 56 | return NextResponse.json( 57 | { error: 'Failed to post to backend' }, 58 | { status: 500 } 59 | ); 60 | } 61 | } 62 | 63 | 64 | export async function PUT(request, { params }) { 65 | const path = (await params).path; 66 | const url = urlJoin(DJANGO_API_URL, path) + request.nextUrl.search; 67 | try { 68 | const body = await request.json(); 69 | const response = await TokenFetch.fetch(url, { 70 | method: 'put', 71 | headers: { 72 | 'Content-Type': 'application/json', 73 | // Forward other headers as needed 74 | }, 75 | body: JSON.stringify(body), 76 | }); 77 | const data = await response.json(); 78 | if (!response.ok) { 79 | return NextResponse.json( 80 | data, 81 | { status: response.status } 82 | ); 83 | } 84 | 85 | 86 | return NextResponse.json(data, { status: response.status }); 87 | } catch (error) { 88 | return NextResponse.json( 89 | { error: 'Failed to post to backend' }, 90 | { status: 500 } 91 | ); 92 | } 93 | } -------------------------------------------------------------------------------- /frontend/src/app/api/backend/healthz/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import {urlJoin} from '@/lib/urlJoin'; 3 | 4 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 5 | 6 | export async function GET(request) { 7 | const url = urlJoin(DJANGO_API_URL, "/healthz") 8 | try { 9 | const response = await fetch(url, { 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | }, 13 | method: 'GET', 14 | }); 15 | 16 | if (response.ok) { 17 | return NextResponse.json({ status: 'healthy' }, { status: 200 }); 18 | } else { 19 | return NextResponse.json({ status: 'unhealthy' }, { status: 503 }); 20 | } 21 | } catch (error) { 22 | return NextResponse.json( 23 | { status: 'unhealthy' }, 24 | { status: 503 } 25 | ); 26 | } 27 | } -------------------------------------------------------------------------------- /frontend/src/app/api/ckeditor/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | // You can configure this to point to your backend 4 | const CKEDITOR_LICENSE_KEY = process.env.CKEDITOR_LICENSE_KEY; 5 | 6 | export async function GET(request) { 7 | return NextResponse.json({"license": CKEDITOR_LICENSE_KEY}); 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/api/google/callback/route.js: -------------------------------------------------------------------------------- 1 | "use server" 2 | import { setRefreshToken, setToken } from '@/lib/auth' 3 | import { NextResponse } from 'next/server' 4 | import { urlJoin } from '@/lib/urlJoin' 5 | 6 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 7 | 8 | export async function POST(request) { 9 | const requestData = await request.json() 10 | const url = urlJoin(DJANGO_API_URL, "google/callback") 11 | const jsonData = JSON.stringify(requestData) 12 | const requestOptions = { 13 | method: "POST", 14 | headers: { 15 | "Content-Type": "application/json" 16 | }, 17 | body: jsonData 18 | } 19 | const response = await fetch(url, requestOptions) 20 | const responseData = await response.json() 21 | if (response.ok) { 22 | const accessToken = responseData.access_token || responseData.access 23 | const refreshToken = responseData.refresh_token || responseData.refresh 24 | if (!accessToken || !refreshToken) { 25 | return NextResponse.json({"detail": "Invalid response from server. Please try again."}, {status: 400}) 26 | } 27 | await setToken(accessToken) 28 | await setRefreshToken(refreshToken) 29 | return NextResponse.json({"loggedIn": true, "username": responseData.username}, {status: 200}) 30 | } 31 | return NextResponse.json({"loggedIn": false, ...responseData}, {status: 400}) 32 | } -------------------------------------------------------------------------------- /frontend/src/app/api/health/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | // You can configure this to point to your backend 4 | const BACKEND_URL = process.env.DJANGO_BASE_URL; 5 | 6 | export async function GET(request) { 7 | return NextResponse.json({"django_base_url": BACKEND_URL, "status": "ok"}); 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/app/api/login/route.js: -------------------------------------------------------------------------------- 1 | "use server" 2 | import { setRefreshToken, setToken } from '@/lib/auth' 3 | import { NextResponse } from 'next/server' 4 | import { urlJoin } from '@/lib/urlJoin' 5 | 6 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 7 | 8 | export async function POST(request) { 9 | const requestData = await request.json() 10 | const url = urlJoin(DJANGO_API_URL, "login") 11 | const jsonData = JSON.stringify(requestData) 12 | const requestOptions = { 13 | method: "POST", 14 | headers: { 15 | "Content-Type": "application/json" 16 | }, 17 | body: jsonData 18 | } 19 | const response = await fetch(url, requestOptions) 20 | const responseData = await response.json() 21 | if (response.ok) { 22 | const accessToken = responseData.access_token || responseData.access 23 | const refreshToken = responseData.refresh_token || responseData.refresh 24 | if (!accessToken || !refreshToken) { 25 | return NextResponse.json({"detail": "Invalid response from server. Please try again."}, {status: 400}) 26 | } 27 | await setToken(accessToken) 28 | await setRefreshToken(refreshToken) 29 | return NextResponse.json({"loggedIn": true, "username": responseData.username}, {status: 200}) 30 | } 31 | return NextResponse.json({"loggedIn": false, ...responseData}, {status: 400}) 32 | } -------------------------------------------------------------------------------- /frontend/src/app/api/logout/route.js: -------------------------------------------------------------------------------- 1 | import { deleteTokens } from "@/lib/auth"; 2 | import { NextResponse } from "next/server"; 3 | 4 | 5 | export async function POST(request) { 6 | await deleteTokens() 7 | return NextResponse.json({}, {status: 200}) 8 | } -------------------------------------------------------------------------------- /frontend/src/app/api/signup/route.js: -------------------------------------------------------------------------------- 1 | "use server" 2 | import { setRefreshToken, setToken } from '@/lib/auth' 3 | import { NextResponse } from 'next/server' 4 | import { urlJoin } from '@/lib/urlJoin' 5 | 6 | const DJANGO_API_URL = process.env.DJANGO_API_URL; 7 | 8 | export async function POST(request) { 9 | const requestData = await request.json() 10 | const url = urlJoin(DJANGO_API_URL, "signup") 11 | const jsonData = JSON.stringify(requestData) 12 | const requestOptions = { 13 | method: "POST", 14 | headers: { 15 | "Content-Type": "application/json" 16 | }, 17 | body: jsonData 18 | } 19 | const response = await fetch(url, requestOptions) 20 | const responseData = await response.json() 21 | if (response.ok) { 22 | const accessToken = responseData.access_token || responseData.token 23 | const refreshToken = responseData.refresh_token || responseData.refresh 24 | if (!accessToken || !refreshToken) { 25 | return NextResponse.json({"detail": "Invalid response from server. Please try again."}, {status: 400}) 26 | } 27 | await setToken(accessToken) 28 | await setRefreshToken(refreshToken) 29 | return NextResponse.json({"loggedIn": true, "username": responseData.username}, {status: 200}) 30 | } 31 | return NextResponse.json({"loggedIn": false, ...responseData}, {status: 400}) 32 | } -------------------------------------------------------------------------------- /frontend/src/app/docs/[docId]/page.js: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | 4 | import { useAuth } from "@/components/authProvider"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | import { Textarea } from "@/components/ui/textarea"; 8 | import fetcher from "@/lib/fetcher"; 9 | import dynamic from "next/dynamic"; 10 | import Link from "next/link"; 11 | import { useParams } from "next/navigation"; 12 | import { useRef, useState } from "react"; 13 | 14 | import useSWR from "swr"; 15 | 16 | const DocEditor = dynamic( () => import( '@/components/editor/DocEditor' ), { ssr: false } ); 17 | 18 | 19 | export default function DocDetailPage() { 20 | const {docId} = useParams() 21 | const editorRef = useRef(null) 22 | const submitBtnRef = useRef(null) 23 | const [saving, setSaving] = useState(false) 24 | const {isAuthenticated} = useAuth() 25 | const apiEndpoint = `/api/documents/${docId}` 26 | const {data:doc, isLoading, error, mutate} = useSWR(apiEndpoint, fetcher) 27 | const [formError, setFormError] = useState("") 28 | 29 | 30 | if (isLoading) { 31 | return
Loading..
32 | } 33 | if (error) { 34 | if (!isAuthenticated && error.status === 401) { 35 | window.location.href='/login' 36 | } 37 | if (isAuthenticated && error.status === 401) { 38 | return
Invite required
39 | } 40 | if (error.status === 404) { 41 | return
Doc not found
42 | } 43 | return
{error.message} {error.status}
44 | } 45 | async function handleSubmit (event) { 46 | event.preventDefault() 47 | setSaving(true) 48 | setFormError("") // Clear any previous errors 49 | const content = editorRef.current.editor.getData() 50 | const formData = new FormData(event.target) 51 | const objectFromForm = Object.fromEntries(formData) 52 | objectFromForm['content'] = content 53 | const jsonData = JSON.stringify(objectFromForm) 54 | const requestOptions = { 55 | method: "PUT", 56 | headers: { 57 | "Content-Type": "application/json" 58 | }, 59 | body: jsonData 60 | } 61 | const response = await fetch(apiEndpoint, requestOptions) 62 | let data = {} 63 | try { 64 | data = await response.json() 65 | } catch (error) { 66 | 67 | } 68 | // const data = await response.json() 69 | if (response.ok) { 70 | setSaving(false) 71 | mutate() 72 | } else { 73 | setSaving(false) 74 | console.log(data) 75 | setFormError(data.message || "Save failed.") 76 | } 77 | } 78 | 79 | const handleOnSave = (editor) => { 80 | console.log(editor) 81 | submitBtnRef.current.click() 82 | } 83 | 84 | 85 | const title = doc?.title ? doc.title : "Document" 86 | return <> 87 |
88 |

{title}

89 |
90 | {formError && ( 91 |
92 | {formError} 93 |
94 | )} 95 | 96 | 101 | 102 | 103 | {saving &&

Saving...

} 104 | 105 |
106 | 107 | } 108 | -------------------------------------------------------------------------------- /frontend/src/app/docs/create/page.js: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | 4 | import { useAuth } from "@/components/authProvider"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | import { Textarea } from "@/components/ui/textarea"; 8 | import fetcher from "@/lib/fetcher"; 9 | import Link from "next/link"; 10 | import { useParams } from "next/navigation"; 11 | import { useState } from "react"; 12 | 13 | import useSWR from "swr"; 14 | 15 | export default function DocCreatePage() { 16 | // const {isAuthenticated} = useAuth() 17 | const apiEndpoint = `/api/documents/` 18 | const [formError, setFormError] = useState("") 19 | // if (!isAuthenticated) { 20 | // window.location.href='/login' 21 | // } 22 | async function handleSubmit (event) { 23 | event.preventDefault() 24 | setFormError("") // Clear any previous errors 25 | const formData = new FormData(event.target) 26 | const objectFromForm = Object.fromEntries(formData) 27 | const jsonData = JSON.stringify(objectFromForm) 28 | const requestOptions = { 29 | method: "POST", 30 | headers: { 31 | "Content-Type": "application/json" 32 | }, 33 | body: jsonData 34 | } 35 | const response = await fetch(apiEndpoint, requestOptions) 36 | let data = {} 37 | try { 38 | data = await response.json() 39 | } catch (error) { 40 | 41 | } 42 | // const data = await response.json() 43 | if (response.ok) { 44 | console.log(data) 45 | window.location.href = `/docs/${data.id}` 46 | // redirect(`/docs/{data.id}`) 47 | } else { 48 | console.log(data) 49 | setFormError(data.message || "Save failed.") 50 | } 51 | } 52 | 53 | 54 | const title = "Create new Document" 55 | return <> 56 |
57 |
58 |
59 |

{title}

60 |
61 | {formError && ( 62 |
63 | {formError} 64 |
65 | )} 66 | 67 | 68 |
69 | 70 |
71 |
72 |
73 | 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/app/docs/page.js: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | 4 | import fetcher from "@/lib/fetcher"; 5 | import Link from "next/link"; 6 | 7 | import useSWR from "swr"; 8 | 9 | export default function DocListPage() { 10 | const {data, isLoading, error} = useSWR("/api/documents/", fetcher) 11 | const isResultsArray = Array.isArray(data) 12 | const results = data && isResultsArray ? data : [] 13 | console.log(results) 14 | if (error) { 15 | if (error.status === 401) { 16 | window.location.href='/login' 17 | } 18 | return
{error.message} {error.status}
19 | } 20 | return <> 21 |
22 |

Documents

23 | 33 |
34 | 35 | } 36 | -------------------------------------------------------------------------------- /frontend/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/google-docs-with-django-nextjs/02499203dd2a41128ca2063e472f8d993a842344/frontend/src/app/favicon.ico -------------------------------------------------------------------------------- /frontend/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 0 0% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 0 0% 3.9%; 15 | 16 | --primary: 0 0% 9%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | 22 | --muted: 0 0% 96.1%; 23 | --muted-foreground: 0 0% 45.1%; 24 | 25 | --accent: 0 0% 96.1%; 26 | --accent-foreground: 0 0% 9%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 0 0% 89.8%; 32 | --input: 0 0% 89.8%; 33 | --ring: 0 0% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | 37 | --chart-1: 12 76% 61%; 38 | 39 | --chart-2: 173 58% 39%; 40 | 41 | --chart-3: 197 37% 24%; 42 | 43 | --chart-4: 43 74% 66%; 44 | 45 | --chart-5: 27 87% 67%; 46 | } 47 | 48 | .dark { 49 | --background: 0 0% 3.9%; 50 | --foreground: 0 0% 98%; 51 | 52 | --card: 0 0% 3.9%; 53 | --card-foreground: 0 0% 98%; 54 | 55 | --popover: 0 0% 3.9%; 56 | --popover-foreground: 0 0% 98%; 57 | 58 | --primary: 0 0% 98%; 59 | --primary-foreground: 0 0% 9%; 60 | 61 | --secondary: 0 0% 14.9%; 62 | --secondary-foreground: 0 0% 98%; 63 | 64 | --muted: 0 0% 14.9%; 65 | --muted-foreground: 0 0% 63.9%; 66 | 67 | --accent: 0 0% 14.9%; 68 | --accent-foreground: 0 0% 98%; 69 | 70 | --destructive: 0 62.8% 30.6%; 71 | --destructive-foreground: 0 0% 98%; 72 | 73 | --border: 0 0% 14.9%; 74 | --input: 0 0% 14.9%; 75 | --ring: 0 0% 83.1%; 76 | --chart-1: 220 70% 50%; 77 | --chart-2: 160 60% 45%; 78 | --chart-3: 30 80% 55%; 79 | --chart-4: 280 65% 60%; 80 | --chart-5: 340 75% 55%; 81 | } 82 | } 83 | 84 | @layer base { 85 | * { 86 | @apply border-border; 87 | } 88 | body { 89 | @apply bg-background text-foreground; 90 | } 91 | } -------------------------------------------------------------------------------- /frontend/src/app/google/GoogleLoginButton.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import { useState } from 'react' 3 | 4 | export default function GoogleLoginButton() { 5 | const [isLoading, setIsLoading] = useState(false) 6 | 7 | const handleGoogleLogin = async () => { 8 | try { 9 | setIsLoading(true) 10 | const response = await fetch('/api/google/login') 11 | const data = await response.json() 12 | 13 | // Redirect to Google's OAuth page 14 | window.location.href = data.redirect_url 15 | } catch (error) { 16 | console.error('Google login error:', error) 17 | } finally { 18 | setIsLoading(false) 19 | } 20 | } 21 | 22 | return ( 23 | 31 | ) 32 | } -------------------------------------------------------------------------------- /frontend/src/app/google/callback/page.jsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect } from 'react'; 3 | import { useRouter, useSearchParams } from 'next/navigation'; 4 | import { useAuth } from '@/components/authProvider'; 5 | 6 | export default function GoogleCallback() { 7 | const auth = useAuth() 8 | const router = useRouter(); 9 | const searchParams = useSearchParams(); 10 | 11 | useEffect(() => { 12 | const handleCallback = async () => { 13 | const code = searchParams.get('code'); 14 | const state = searchParams.get('state'); 15 | 16 | if (!code || !state) { 17 | console.error('Missing code or state parameters'); 18 | router.push('/login'); // Redirect to login on error 19 | return; 20 | } 21 | 22 | try { 23 | const response = await fetch('/api/google/callback', { 24 | method: 'POST', 25 | headers: { 26 | 'Content-Type': 'application/json', 27 | }, 28 | body: JSON.stringify({ code, state }), 29 | }); 30 | 31 | let data = {} 32 | try { 33 | data = await response.json() 34 | } catch (error) { 35 | 36 | } 37 | // const data = await response.json() 38 | if (response.ok) { 39 | auth.login(data?.username) 40 | } else { 41 | setError(data.message || "Login failed. Please check your credentials.") 42 | } 43 | 44 | router.push('/'); // Redirect to dashboard on success 45 | } catch (error) { 46 | console.error('Error during authentication:', error); 47 | router.push('/login'); // Redirect to login on error 48 | } 49 | }; 50 | 51 | handleCallback(); 52 | }, [searchParams, router]); 53 | 54 | return ( 55 |
56 |
57 |

Authenticating...

58 |

Please wait while we complete your sign-in.

59 |
60 |
61 | ); 62 | } -------------------------------------------------------------------------------- /frontend/src/app/healthz/route.js: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | 3 | export async function GET(request) { 4 | return NextResponse.json({ message: "OK" }); 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/app/layout.js: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | 3 | import { Inter as FontSans } from "next/font/google" 4 | 5 | import { APIProvider } from "@/components/apiProvider"; 6 | import { AuthProvider } from "@/components/authProvider"; 7 | import { ThemeProvider } from "@/components/themeProvider"; 8 | import BaseLayout from "@/components/layout/BaseLayout"; 9 | import { cn } from "@/lib/utils" 10 | 11 | import "./globals.css"; 12 | 13 | const fontSans = FontSans({ 14 | subsets: ["latin"], 15 | variable: "--font-sans", 16 | }) 17 | 18 | export const metadata = { 19 | title: "Django x Next.js", 20 | description: "Django x Next.js", 21 | }; 22 | 23 | export default function RootLayout({ children }) { 24 | return ( 25 | 26 | 30 | Loading...}> 31 | 35 | 36 | 37 | 38 | {children} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | } -------------------------------------------------------------------------------- /frontend/src/app/login/page.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Image from "next/image" 4 | import Link from "next/link" 5 | import { useState } from "react" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { Input } from "@/components/ui/input" 9 | import { Label } from "@/components/ui/label" 10 | import { useAuth } from "@/components/authProvider" 11 | import GoogleLoginButton from "../google/GoogleLoginButton" 12 | 13 | const LOGIN_URL = "/api/login/" 14 | 15 | 16 | export default function Page() { 17 | const auth = useAuth() 18 | const [error, setError] = useState("") 19 | 20 | async function handleSubmit (event) { 21 | event.preventDefault() 22 | setError("") // Clear any previous errors 23 | const formData = new FormData(event.target) 24 | const objectFromForm = Object.fromEntries(formData) 25 | const jsonData = JSON.stringify(objectFromForm) 26 | const requestOptions = { 27 | method: "POST", 28 | headers: { 29 | "Content-Type": "application/json" 30 | }, 31 | body: jsonData 32 | } 33 | const response = await fetch(LOGIN_URL, requestOptions) 34 | let data = {} 35 | try { 36 | data = await response.json() 37 | } catch (error) { 38 | 39 | } 40 | // const data = await response.json() 41 | if (response.ok) { 42 | auth.login(data?.username) 43 | } else { 44 | setError(data.message || "Login failed. Please check your credentials.") 45 | } 46 | } 47 | return ( 48 |
49 |
50 |
51 |
52 |

Login

53 |

54 | Enter your email below to login to your account 55 |

56 |
57 |
58 | 59 |
60 | 61 |
62 |
63 | {error && ( 64 |
65 | {error} 66 |
67 | )} 68 |
69 | 70 | 77 |
78 |
79 |
80 | 81 | 82 |
83 | 84 | 85 |
86 | 87 | 88 | 91 | 92 |
93 |
94 |
95 | Don't have an account?{" "} 96 | 97 | Sign up 98 | 99 |
100 |
101 |
102 |
103 | Image 110 |
111 |
112 | ) 113 | } -------------------------------------------------------------------------------- /frontend/src/app/logout/page.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useAuth } from "@/components/authProvider" 4 | const LOGOUT_URL = "/api/logout/" 5 | 6 | 7 | export default function Page() { 8 | const auth = useAuth() 9 | async function handleClick (event) { 10 | event.preventDefault() 11 | const requestOptions = { 12 | method: "POST", 13 | headers: { 14 | "Content-Type": "application/json" 15 | }, 16 | body: "" 17 | } 18 | const response = await fetch(LOGOUT_URL, requestOptions) 19 | if (response.ok) { 20 | auth.logout() 21 | } 22 | } 23 | return
24 |
25 |

Are you sure you want to logout?

26 | 27 |
28 |
29 | } -------------------------------------------------------------------------------- /frontend/src/app/page.js: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | 7 | 8 | 9 | export default function Home() { 10 | return ( 11 |
14 |
15 | 16 | 17 | 18 | 21 | 24 |
25 | 57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /frontend/src/app/signup/page.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Image from "next/image" 4 | import Link from "next/link" 5 | import { useState } from "react" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { Input } from "@/components/ui/input" 9 | import { Label } from "@/components/ui/label" 10 | import { useAuth } from "@/components/authProvider" 11 | 12 | const LOGIN_URL = "/api/signup/" 13 | 14 | 15 | export default function Page() { 16 | const auth = useAuth() 17 | const [error, setError] = useState("") 18 | const [fieldErrors, setFieldErrors] = useState({}) 19 | 20 | async function handleSubmit(event) { 21 | event.preventDefault() 22 | setError("") 23 | setFieldErrors({}) 24 | const formData = new FormData(event.target) 25 | const objectFromForm = Object.fromEntries(formData) 26 | 27 | if (objectFromForm.password !== objectFromForm.confirm_password) { 28 | setError("Passwords do not match") 29 | return 30 | } 31 | 32 | const jsonData = JSON.stringify(objectFromForm) 33 | const requestOptions = { 34 | method: "POST", 35 | headers: { 36 | "Content-Type": "application/json" 37 | }, 38 | body: jsonData 39 | } 40 | 41 | const response = await fetch(LOGIN_URL, requestOptions) 42 | 43 | let data = {} 44 | try { 45 | data = await response.json() 46 | } catch (error) { 47 | setError("An unexpected error occurred") 48 | return 49 | } 50 | 51 | if (response.ok) { 52 | auth.login(data?.username) 53 | } else { 54 | if (data.detail && Array.isArray(data.detail)) { 55 | const newFieldErrors = {} 56 | data.detail.forEach(error => { 57 | const fieldName = error.loc[error.loc.length - 1] 58 | const mappedFieldName = fieldName === 'confirm_password' ? 'confirmPassword' : fieldName 59 | newFieldErrors[mappedFieldName] = error.msg 60 | }) 61 | setFieldErrors(newFieldErrors) 62 | } else { 63 | setError(data.message || "Signup failed. Please try again.") 64 | } 65 | } 66 | } 67 | return ( 68 |
69 |
70 |
71 |
72 |

Sign Up

73 |

74 | Create an account to get started 75 |

76 |
77 |
78 |
79 | {error && ( 80 |
81 | {error} 82 |
83 | )} 84 |
85 | 86 | 93 | {fieldErrors.email && ( 94 |

{fieldErrors.email}

95 | )} 96 |
97 |
98 | 99 | 106 | {fieldErrors.password && ( 107 |

{fieldErrors.password}

108 | )} 109 |
110 |
111 | 112 | 119 | {fieldErrors.confirmPassword && ( 120 |

{fieldErrors.confirmPassword}

121 | )} 122 |
123 | 126 |
127 |
128 |
129 | Already have an account?{" "} 130 | 131 | Login 132 | 133 |
134 |
135 |
136 |
137 | Image 144 |
145 |
146 | ) 147 | } -------------------------------------------------------------------------------- /frontend/src/components/apiProvider.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { createContext, useContext, useEffect, useState } from 'react'; 4 | import useMySWR from "@/components/useMySWR"; 5 | const APIContext = createContext(); 6 | 7 | export function APIProvider({ children }) { 8 | const { data, error, isLoading } = useMySWR("/api/backend/healthz"); 9 | const isHealthy = !error && data?.status === "healthy"; 10 | 11 | return ( 12 | 13 | {children} 14 | 15 | ); 16 | } 17 | 18 | export function useAPI() { 19 | const context = useContext(APIContext); 20 | if (context === undefined) { 21 | throw new Error('useAPI must be used within an APIProvider'); 22 | } 23 | return context; 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/components/authProvider.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { usePathname, useRouter, useSearchParams } from "next/navigation"; 4 | 5 | const { createContext, useContext, useState, useEffect } = require("react"); 6 | const AuthContext = createContext(null); 7 | 8 | const LOGIN_REDIRECT_URL = "/" 9 | const LOGOUT_REDIRECT_URL = "/login" 10 | const LOGIN_REQUIRED_URL = "/login" 11 | const LOCAL_STORAGE_KEY = "is-logged-in" 12 | const LOCAL_USERNAME_KEY = "username" 13 | 14 | export function AuthProvider({children}) { 15 | const [isAuthenticated, setIsAuthenticated] = useState(false) 16 | const [username, setUsername] = useState("") 17 | const router = useRouter() 18 | const pathname = usePathname() 19 | const searchParams = useSearchParams() 20 | 21 | useEffect(()=>{ 22 | const storedAuthStatus = localStorage.getItem(LOCAL_STORAGE_KEY) 23 | if (storedAuthStatus) { 24 | const storedAuthStatusInt = parseInt(storedAuthStatus) 25 | setIsAuthenticated(storedAuthStatusInt===1) 26 | } 27 | const storedUn = localStorage.getItem(LOCAL_USERNAME_KEY) 28 | if (storedUn) { 29 | setUsername(storedUn) 30 | } 31 | },[]) 32 | 33 | const login = (username) => { 34 | setIsAuthenticated(true) 35 | localStorage.setItem(LOCAL_STORAGE_KEY, "1") 36 | if (username) { 37 | localStorage.setItem(LOCAL_USERNAME_KEY, `${username}`) 38 | setUsername(username) 39 | } else { 40 | localStorage.removeItem(LOCAL_USERNAME_KEY) 41 | } 42 | const nextUrl = searchParams.get("next") 43 | const invalidNextUrl = ['/login', '/logout'] 44 | const nextUrlValid = nextUrl && nextUrl.startsWith("/") && !invalidNextUrl.includes(nextUrl) 45 | if (nextUrlValid) { 46 | router.replace(nextUrl) 47 | return 48 | } else { 49 | router.replace(LOGIN_REDIRECT_URL) 50 | return 51 | } 52 | } 53 | const logout = () => { 54 | setIsAuthenticated(false) 55 | localStorage.setItem(LOCAL_STORAGE_KEY, "0") 56 | router.replace(LOGOUT_REDIRECT_URL) 57 | } 58 | const loginRequiredRedirect = () => { 59 | // user is not logged in via API 60 | setIsAuthenticated(false) 61 | localStorage.setItem(LOCAL_STORAGE_KEY, "0") 62 | let loginWithNextUrl = `${LOGIN_REQUIRED_URL}?next=${pathname}` 63 | if (LOGIN_REQUIRED_URL === pathname) { 64 | loginWithNextUrl = `${LOGIN_REQUIRED_URL}` 65 | } 66 | router.replace(loginWithNextUrl) 67 | } 68 | return 69 | {children} 70 | 71 | } 72 | 73 | export function useAuth(){ 74 | return useContext(AuthContext) 75 | } -------------------------------------------------------------------------------- /frontend/src/components/editor/DocEditor.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState, useEffect, useRef, useMemo } from 'react'; 4 | import { CKEditor } from '@ckeditor/ckeditor5-react'; 5 | import { CloudServices, ClassicEditor, AutoLink, Autosave, BlockQuote, Bold, Essentials, Heading, Italic, Link, Paragraph, Underline } from 'ckeditor5'; 6 | import { PresenceList, RealTimeCollaborativeEditing, AIAssistant, AIRequestError, AITextAdapter } from 'ckeditor5-premium-features'; 7 | 8 | import 'ckeditor5/ckeditor5.css'; 9 | import 'ckeditor5-premium-features/ckeditor5-premium-features.css'; 10 | 11 | 12 | import 'ckeditor5/ckeditor5.css'; 13 | import './docEditor.css'; 14 | import useSWR from 'swr'; 15 | import fetcher from '@/lib/fetcher'; 16 | 17 | const COLAB_PLUGINS = [ 18 | CloudServices, 19 | PresenceList, 20 | RealTimeCollaborativeEditing 21 | 22 | ] 23 | 24 | class CustomerAITextAdapter extends AITextAdapter { 25 | async sendRequest ( requestData ) { 26 | const {query, context} = requestData 27 | const endpoint = '/api/ai/' 28 | const options = { 29 | method: "POST", 30 | headers: { 31 | 'Content-Type': 'application/json' 32 | }, 33 | body: JSON.stringify({ 34 | query: query, 35 | context: context 36 | }) 37 | } 38 | const apiR = await fetch(endpoint, options) 39 | if (!apiR.ok) { 40 | throw AIRequestError("The request failed for unknown reason") 41 | } 42 | const data = await apiR.json() 43 | requestData.onData(data.message) 44 | } 45 | } 46 | 47 | // /api/accounts/ckeditor/token/ 48 | const CLOUD_SERVICES_TOKEN_URL = 49 | 'https://ad01wo2dm__n.cke-cs.com/token/dev/cfef2eb70dfcfd047a067464f456a01ba132626e078c41088531cf33b044?limit=10'; 50 | const CLOUD_SERVICES_WEBSOCKET_URL = 'wss://ad01wo2dm__n.cke-cs.com/ws'; 51 | 52 | export default function DocEditor({ref, initialData, placeholder, onSave, docId}) { 53 | const [isLayoutReady, setIsLayoutReady] = useState(false); 54 | const {data, isLoading} = useSWR('/api/ckeditor', fetcher) 55 | const editorPresenceRef = useRef(null) 56 | const license = data?.license ? data?.license : 'GPL' 57 | 58 | useEffect(() => { 59 | setIsLayoutReady(true); 60 | 61 | return () => setIsLayoutReady(false); 62 | }, []); 63 | 64 | const fetchUserToken = async () => { 65 | const endpoint = '/api/accounts/ckeditor/token/' 66 | const options = { 67 | method: "GET", 68 | headers: { 69 | 'Content-Type': 'application/json' 70 | }, 71 | } 72 | const response = await fetch(endpoint, options) 73 | if (!response.ok) { 74 | throw new Error("Invalid token") 75 | } 76 | const data = await response.json() 77 | const {myUserToken} = data 78 | if (!myUserToken) { 79 | throw new Error("Invalid token") 80 | } 81 | return myUserToken 82 | } 83 | 84 | const { editorConfig } = useMemo(() => { 85 | if (!isLayoutReady) { 86 | return {}; 87 | } 88 | if (isLoading) { 89 | return {}; 90 | } 91 | 92 | return { 93 | editorConfig: { 94 | toolbar: { 95 | items: ['aiCommands', 'aiAssistant', '|','heading', 'bold', 'italic', 'underline', 'blockquote', '|', 'link'], 96 | shouldNotGroupWhenFull: false 97 | }, 98 | plugins: COLAB_PLUGINS.concat([AIAssistant, CustomerAITextAdapter, AutoLink, Autosave, BlockQuote, Bold, Essentials, Heading, Italic, Link, Paragraph, Underline]), 99 | cloudServices: { 100 | tokenUrl: fetchUserToken, 101 | webSocketUrl: CLOUD_SERVICES_WEBSOCKET_URL 102 | }, 103 | collaboration: { 104 | channelId: `${docId}`, 105 | }, 106 | presenceList: { 107 | container: editorPresenceRef.current 108 | }, 109 | heading: { 110 | options: [ 111 | { 112 | model: 'paragraph', 113 | title: 'Paragraph', 114 | class: 'ck-heading_paragraph' 115 | }, 116 | { 117 | model: 'heading1', 118 | view: 'h1', 119 | title: 'Heading 1', 120 | class: 'ck-heading_heading1' 121 | }, 122 | { 123 | model: 'heading2', 124 | view: 'h2', 125 | title: 'Heading 2', 126 | class: 'ck-heading_heading2' 127 | }, 128 | { 129 | model: 'heading3', 130 | view: 'h3', 131 | title: 'Heading 3', 132 | class: 'ck-heading_heading3' 133 | }, 134 | { 135 | model: 'heading4', 136 | view: 'h4', 137 | title: 'Heading 4', 138 | class: 'ck-heading_heading4' 139 | }, 140 | { 141 | model: 'heading5', 142 | view: 'h5', 143 | title: 'Heading 5', 144 | class: 'ck-heading_heading5' 145 | }, 146 | { 147 | model: 'heading6', 148 | view: 'h6', 149 | title: 'Heading 6', 150 | class: 'ck-heading_heading6' 151 | } 152 | ] 153 | }, 154 | autosave: onSave ? { 155 | waitingTime: 5000, 156 | save: onSave, 157 | } : null, 158 | initialData: initialData ? initialData: '', 159 | licenseKey: license, 160 | link: { 161 | addTargetToExternalLinks: true, 162 | defaultProtocol: 'https://', 163 | decorators: { 164 | toggleDownloadable: { 165 | mode: 'manual', 166 | label: 'Downloadable', 167 | attributes: { 168 | download: 'file' 169 | } 170 | } 171 | } 172 | }, 173 | placeholder: placeholder ? placeholder : 'Type or paste your content here!' 174 | } 175 | }; 176 | }, [isLayoutReady, isLoading]); 177 | 178 | return
179 |
180 | 181 | {editorConfig && } 182 |
183 | } 184 | -------------------------------------------------------------------------------- /frontend/src/components/editor/docEditor.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,400;0,700;1,400;1,700&display=swap'); 2 | 3 | @media print { 4 | body { 5 | margin: 0 !important; 6 | } 7 | } 8 | 9 | .main-container { 10 | font-family: 'Lato'; 11 | width: fit-content; 12 | margin-left: auto; 13 | margin-right: auto; 14 | } 15 | 16 | .ck-content { 17 | font-family: 'Lato'; 18 | line-height: 1.6; 19 | word-break: break-word; 20 | } 21 | 22 | .editor-container_classic-editor .editor-container__editor { 23 | min-width: 795px; 24 | max-width: 795px; 25 | } 26 | 27 | .prose { 28 | width: 100%; 29 | max-width: 100% !important; 30 | } 31 | 32 | .ck-editor__editable { 33 | min-height: 200px; 34 | width: 100%; 35 | } 36 | 37 | .ck.ck-editor__main>.ck-editor__editable { 38 | width: 100%; 39 | } 40 | 41 | -------------------------------------------------------------------------------- /frontend/src/components/layout/AccountDropdown.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | 4 | import { CircleUser, } from "lucide-react" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuLabel, 12 | DropdownMenuSeparator, 13 | DropdownMenuTrigger, 14 | } from "@/components/ui/dropdown-menu" 15 | import { useAuth } from "../authProvider" 16 | import { useRouter } from "next/navigation" 17 | 18 | 19 | 20 | export default function AccountDropdown({className}) { 21 | const auth = useAuth() 22 | const router = useRouter() 23 | 24 | return 25 | 26 | 30 | 31 | 32 | {auth.username? auth.username : "Account"} 33 | router.push('/logout')}>Logout 34 | 35 | 36 | } -------------------------------------------------------------------------------- /frontend/src/components/layout/BaseLayout.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Navbar from './Navbar' 4 | 5 | 6 | export default function BaseLayout({ children, className}) { 7 | const mainClassName = className ? className : "flex min-h-[calc(100vh_-_theme(spacing.16))] flex-1 flex-col gap-4 bg-muted/40 p-4 md:gap-8 md:p-10" 8 | return ( 9 |
10 | 11 |
12 | {children} 13 |
14 |
15 | ) 16 | } -------------------------------------------------------------------------------- /frontend/src/components/layout/BrandLink.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import Image from "next/image" 5 | 6 | export default function BrandLink({className}){ 7 | const finalClass = className ? className : "flex items-center gap-2 text-lg font-semibold md:text-base" 8 | return 12 | Django Next.js Icon 13 | Django Next.js 14 | 15 | } -------------------------------------------------------------------------------- /frontend/src/components/layout/MobileNavbar.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import { Menu } from "lucide-react" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet" 8 | import { useAuth } from "../authProvider" 9 | 10 | import NavLinks, {NonUserLinks} from './NavLinks' 11 | import BrandLink from "./BrandLink" 12 | import { useAPI } from "../apiProvider" 13 | 14 | 15 | 16 | export default function MobileNavbar({className}) { 17 | const { isHealthy } = useAPI(); 18 | const auth = useAuth() 19 | return 20 | 21 | 29 | 30 | 31 | Navigation Menu 32 | 72 | 73 | 74 | } -------------------------------------------------------------------------------- /frontend/src/components/layout/NavLinks.jsx: -------------------------------------------------------------------------------- 1 | const NavLinks = [ 2 | { 3 | label: "Dashboard", 4 | authRequired: false, 5 | href: "/" 6 | }, 7 | { 8 | label: "Waitlist", 9 | authRequired: true, 10 | apiHealthRequired: true, 11 | href: "/waitlists" 12 | } 13 | ] 14 | 15 | export const NonUserLinks = [ 16 | { 17 | label: "Signup", 18 | authRequired: false, 19 | apiHealthRequired: true, 20 | href: "/signup" 21 | }, 22 | { 23 | label: "Login", 24 | authRequired: false, 25 | apiHealthRequired: true, 26 | href: "/login" 27 | } 28 | ] 29 | export default NavLinks -------------------------------------------------------------------------------- /frontend/src/components/layout/Navbar.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import { useAuth } from "../authProvider" 5 | import NavLinks, {NonUserLinks} from './NavLinks' 6 | import BrandLink from "./BrandLink" 7 | import MobileNavbar from "./MobileNavbar" 8 | import AccountDropdown from "./AccountDropdown" 9 | import { useAPI } from "../apiProvider" 10 | 11 | 12 | export default function Navbar({className}) { 13 | const { isHealthy } = useAPI(); 14 | const auth = useAuth() 15 | const finalClass = className ? className : "sticky top-0 flex h-16 items-center gap-4 border-b bg-background px-4 md:px-6" 16 | return
17 | 23 | 24 |
25 | 26 |
27 |
28 | 29 | 30 | {auth.isAuthenticated ? 31 |
32 | 33 |
34 | :
35 | {NavLinks.map((navLinkItem, idx)=>{ 36 | const shouldHide = !auth.isAuthenticated && navLinkItem.authRequired 37 | const shouldHideHealthCheck = navLinkItem.apiHealthRequired && !isHealthy 38 | if (shouldHide) { 39 | return null 40 | } 41 | if (shouldHideHealthCheck) { 42 | return null 43 | } 44 | return 49 | {navLinkItem.label} 50 | 51 | })} 52 | 53 | {NonUserLinks.map((navLinkItem, idx)=>{ 54 | const shouldHide = !auth.isAuthenticated &&navLinkItem.authRequired 55 | const shouldHideHealthCheck = navLinkItem.apiHealthRequired && !isHealthy 56 | if (shouldHide) { 57 | return null 58 | } 59 | if (shouldHideHealthCheck) { 60 | return null 61 | } 62 | return 67 | {navLinkItem.label} 68 | 69 | })} 70 |
} 71 | 72 |
73 |
74 | } -------------------------------------------------------------------------------- /frontend/src/components/themeProvider.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ children, ...props }) { 7 | return {children} 8 | } -------------------------------------------------------------------------------- /frontend/src/components/ui/button.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { 38 | const Comp = asChild ? Slot : "button" 39 | return ( 40 | () 44 | ); 45 | }) 46 | Button.displayName = "Button" 47 | 48 | export { Button, buttonVariants } 49 | -------------------------------------------------------------------------------- /frontend/src/components/ui/card.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef(({ className, ...props }, ref) => ( 6 |
10 | )) 11 | Card.displayName = "Card" 12 | 13 | const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( 14 |
18 | )) 19 | CardHeader.displayName = "CardHeader" 20 | 21 | const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( 22 |
26 | )) 27 | CardTitle.displayName = "CardTitle" 28 | 29 | const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( 30 |
34 | )) 35 | CardDescription.displayName = "CardDescription" 36 | 37 | const CardContent = React.forwardRef(({ className, ...props }, ref) => ( 38 |
39 | )) 40 | CardContent.displayName = "CardContent" 41 | 42 | const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( 43 |
47 | )) 48 | CardFooter.displayName = "CardFooter" 49 | 50 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 51 | -------------------------------------------------------------------------------- /frontend/src/components/ui/dropdown-menu.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( 22 | 30 | {children} 31 | 32 | 33 | )) 34 | DropdownMenuSubTrigger.displayName = 35 | DropdownMenuPrimitive.SubTrigger.displayName 36 | 37 | const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( 38 | 45 | )) 46 | DropdownMenuSubContent.displayName = 47 | DropdownMenuPrimitive.SubContent.displayName 48 | 49 | const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( 50 | 51 | 60 | 61 | )) 62 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 63 | 64 | const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( 65 | svg]:size-4 [&>svg]:shrink-0", 69 | inset && "pl-8", 70 | className 71 | )} 72 | {...props} /> 73 | )) 74 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 75 | 76 | const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( 77 | 85 | 86 | 87 | 88 | 89 | 90 | {children} 91 | 92 | )) 93 | DropdownMenuCheckboxItem.displayName = 94 | DropdownMenuPrimitive.CheckboxItem.displayName 95 | 96 | const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( 97 | 104 | 105 | 106 | 107 | 108 | 109 | {children} 110 | 111 | )) 112 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 113 | 114 | const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( 115 | 119 | )) 120 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 121 | 122 | const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( 123 | 127 | )) 128 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 129 | 130 | const DropdownMenuShortcut = ({ 131 | className, 132 | ...props 133 | }) => { 134 | return ( 135 | () 138 | ); 139 | } 140 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 141 | 142 | export { 143 | DropdownMenu, 144 | DropdownMenuTrigger, 145 | DropdownMenuContent, 146 | DropdownMenuItem, 147 | DropdownMenuCheckboxItem, 148 | DropdownMenuRadioItem, 149 | DropdownMenuLabel, 150 | DropdownMenuSeparator, 151 | DropdownMenuShortcut, 152 | DropdownMenuGroup, 153 | DropdownMenuPortal, 154 | DropdownMenuSub, 155 | DropdownMenuSubContent, 156 | DropdownMenuSubTrigger, 157 | DropdownMenuRadioGroup, 158 | } 159 | -------------------------------------------------------------------------------- /frontend/src/components/ui/input.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef(({ className, type, ...props }, ref) => { 6 | return ( 7 | () 15 | ); 16 | }) 17 | Input.displayName = "Input" 18 | 19 | export { Input } 20 | -------------------------------------------------------------------------------- /frontend/src/components/ui/label.jsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva } from "class-variance-authority"; 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef(({ className, ...props }, ref) => ( 14 | 15 | )) 16 | Label.displayName = LabelPrimitive.Root.displayName 17 | 18 | export { Label } 19 | -------------------------------------------------------------------------------- /frontend/src/components/ui/sheet.jsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import * as React from "react" 3 | import * as SheetPrimitive from "@radix-ui/react-dialog" 4 | import { cva } from "class-variance-authority"; 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Sheet = SheetPrimitive.Root 10 | 11 | const SheetTrigger = SheetPrimitive.Trigger 12 | 13 | const SheetClose = SheetPrimitive.Close 14 | 15 | const SheetPortal = SheetPrimitive.Portal 16 | 17 | const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => ( 18 | 25 | )) 26 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName 27 | 28 | const sheetVariants = cva( 29 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", 30 | { 31 | variants: { 32 | side: { 33 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", 34 | bottom: 35 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", 36 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", 37 | right: 38 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", 39 | }, 40 | }, 41 | defaultVariants: { 42 | side: "right", 43 | }, 44 | } 45 | ) 46 | 47 | const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => ( 48 | 49 | 50 | 51 | 53 | 54 | Close 55 | 56 | {children} 57 | 58 | 59 | )) 60 | SheetContent.displayName = SheetPrimitive.Content.displayName 61 | 62 | const SheetHeader = ({ 63 | className, 64 | ...props 65 | }) => ( 66 |
69 | ) 70 | SheetHeader.displayName = "SheetHeader" 71 | 72 | const SheetFooter = ({ 73 | className, 74 | ...props 75 | }) => ( 76 |
79 | ) 80 | SheetFooter.displayName = "SheetFooter" 81 | 82 | const SheetTitle = React.forwardRef(({ className, ...props }, ref) => ( 83 | 87 | )) 88 | SheetTitle.displayName = SheetPrimitive.Title.displayName 89 | 90 | const SheetDescription = React.forwardRef(({ className, ...props }, ref) => ( 91 | 95 | )) 96 | SheetDescription.displayName = SheetPrimitive.Description.displayName 97 | 98 | export { 99 | Sheet, 100 | SheetPortal, 101 | SheetOverlay, 102 | SheetTrigger, 103 | SheetClose, 104 | SheetContent, 105 | SheetHeader, 106 | SheetFooter, 107 | SheetTitle, 108 | SheetDescription, 109 | } 110 | -------------------------------------------------------------------------------- /frontend/src/components/ui/table.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef(({ className, ...props }, ref) => ( 6 |
7 | 11 | 12 | )) 13 | Table.displayName = "Table" 14 | 15 | const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( 16 | 17 | )) 18 | TableHeader.displayName = "TableHeader" 19 | 20 | const TableBody = React.forwardRef(({ className, ...props }, ref) => ( 21 | 25 | )) 26 | TableBody.displayName = "TableBody" 27 | 28 | const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( 29 | tr]:last:border-b-0", className)} 32 | {...props} /> 33 | )) 34 | TableFooter.displayName = "TableFooter" 35 | 36 | const TableRow = React.forwardRef(({ className, ...props }, ref) => ( 37 | 44 | )) 45 | TableRow.displayName = "TableRow" 46 | 47 | const TableHead = React.forwardRef(({ className, ...props }, ref) => ( 48 |
[role=checkbox]]:translate-y-[2px]", 52 | className 53 | )} 54 | {...props} /> 55 | )) 56 | TableHead.displayName = "TableHead" 57 | 58 | const TableCell = React.forwardRef(({ className, ...props }, ref) => ( 59 | [role=checkbox]]:translate-y-[2px]", 63 | className 64 | )} 65 | {...props} /> 66 | )) 67 | TableCell.displayName = "TableCell" 68 | 69 | const TableCaption = React.forwardRef(({ className, ...props }, ref) => ( 70 |
74 | )) 75 | TableCaption.displayName = "TableCaption" 76 | 77 | export { 78 | Table, 79 | TableHeader, 80 | TableBody, 81 | TableFooter, 82 | TableHead, 83 | TableRow, 84 | TableCell, 85 | TableCaption, 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/components/ui/textarea.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Textarea = React.forwardRef(({ className, ...props }, ref) => { 6 | return ( 7 | (