├── .gitattributes ├── .github ├── DISCUSSION_TEMPLATE │ └── questions.yml ├── dependabot.yml └── workflows │ ├── db-migration.yml │ ├── fastapi-to-gcr.yml │ ├── nextjs-preview.yml │ └── nextjs-production.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── architecture.png ├── backend ├── .dockerignore ├── .gitignore ├── docker-compose.yml ├── poetry.lock ├── pyproject.toml ├── scripts │ ├── format.sh │ ├── lint.sh │ ├── prestart.sh │ ├── test.sh │ └── tests-start.sh └── src │ ├── .env.example │ ├── Dockerfile │ ├── app │ ├── __init__.py │ ├── alembic.ini │ ├── alembic │ │ ├── README │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions │ │ │ ├── 0bab391507d0_add_oauth_provider_to_user_table.py │ │ │ ├── ae5a34f2895e_add_chat_table.py │ │ │ ├── da90f41ddaa1_add_chatconfig_table.py │ │ │ └── e2412789c190_initialize_models.py │ ├── api │ │ ├── __init__.py │ │ ├── deps.py │ │ ├── main_route.py │ │ ├── routes │ │ │ ├── __init__.py │ │ │ ├── chat.py │ │ │ ├── items.py │ │ │ ├── login.py │ │ │ ├── shop.py │ │ │ ├── users.py │ │ │ └── utils.py │ │ └── utils.py │ ├── core │ │ ├── __init__.py │ │ ├── config.py │ │ ├── db.py │ │ └── security.py │ ├── crud.py │ ├── email-templates │ │ ├── build │ │ │ ├── new_account.html │ │ │ ├── reset_password.html │ │ │ └── test_email.html │ │ └── src │ │ │ ├── new_account.mjml │ │ │ ├── reset_password.mjml │ │ │ └── test_email.mjml │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── api │ │ │ ├── __init__.py │ │ │ └── routes │ │ │ │ ├── __init__.py │ │ │ │ ├── test_items.py │ │ │ │ ├── test_login.py │ │ │ │ └── test_users.py │ │ ├── conftest.py │ │ ├── crud │ │ │ ├── __init__.py │ │ │ └── test_user.py │ │ ├── scripts │ │ │ ├── __init__.py │ │ │ ├── test_backend_pre_start.py │ │ │ └── test_test_pre_start.py │ │ └── utils │ │ │ ├── __init__.py │ │ │ ├── item.py │ │ │ ├── user.py │ │ │ └── utils.py │ └── utils.py │ ├── backend_pre_start.py │ ├── cloudbuild.yml │ ├── gcr-service-policy.yml │ ├── initial_data.py │ ├── main.py │ ├── requirements.txt │ ├── service-example.yml │ └── tests_pre_start.py ├── deployment.md ├── development.md ├── frontend ├── .dockerignore ├── .env.example ├── .eslintrc.json ├── .gitignore ├── .lintstagedrc.js ├── Dockerfile ├── README.md ├── app │ ├── (protected) │ │ ├── admin │ │ │ ├── actions.tsx │ │ │ └── page.tsx │ │ ├── chat │ │ │ ├── [id] │ │ │ │ └── page.tsx │ │ │ ├── actions.tsx │ │ │ └── page.tsx │ │ ├── dashboard │ │ │ └── page.tsx │ │ ├── items │ │ │ ├── actions.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── settings │ │ │ ├── actions.tsx │ │ │ └── page.tsx │ │ └── shop │ │ │ ├── [id] │ │ │ └── page.tsx │ │ │ ├── actions.tsx │ │ │ └── page.tsx │ ├── callback │ │ ├── github │ │ │ ├── actions.tsx │ │ │ └── page.tsx │ │ └── google │ │ │ ├── actions.tsx │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── login │ │ ├── actions.tsx │ │ ├── page.tsx │ │ └── password-recovery │ │ │ ├── actions.tsx │ │ │ └── page.tsx │ └── page.tsx ├── components.json ├── components │ ├── add-item.tsx │ ├── add-user.tsx │ ├── appearance-settings.tsx │ ├── chat-history.tsx │ ├── chat-settings.tsx │ ├── chat-ui.tsx │ ├── dark-mode-toggle.tsx │ ├── dashboard-header.tsx │ ├── delete-item-dialog.tsx │ ├── delete-user-dialog.tsx │ ├── edit-item-dialog.tsx │ ├── edit-user-dialog.tsx │ ├── github-star.tsx │ ├── items-table.tsx │ ├── main-nav-items.tsx │ ├── mian-nav-items-mob.tsx │ ├── mobile-nav-link.tsx │ ├── my-profile.tsx │ ├── nav-link.tsx │ ├── oauth-login.tsx │ ├── password-reset.tsx │ ├── table-row-item.tsx │ ├── table-row-user.tsx │ ├── theme-provider.tsx │ ├── ui │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── radio-group.tsx │ │ ├── select.tsx │ │ ├── sheet.tsx │ │ ├── submit-button.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts │ └── users-table.tsx ├── lib │ ├── api │ │ ├── index.ts │ │ ├── openapi.json │ │ └── v1.d.ts │ ├── context │ │ └── UserContext.tsx │ ├── definitions.ts │ ├── hooks │ │ └── useChatStream.ts │ └── utils.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public │ ├── euclideanai-favicon-black-transparent.png │ ├── euclideanai-high-resolution-logo-black-1080.png │ ├── euclideanai-high-resolution-logo-black.png │ ├── euclideanai-logo-black-transparent.svg │ ├── euclideanai.png │ ├── fastapi.svg │ ├── icons8-chatgpt.svg │ ├── icons8-claude.svg │ ├── icons8-google.svg │ ├── next.svg │ ├── three-dots-vertical.svg │ └── vercel.svg ├── schema.d.ts ├── tailwind.config.ts └── tsconfig.json ├── hooks └── post_gen_project.py ├── img ├── dashboard-create.png ├── dashboard-dark.png ├── dashboard-items.png ├── dashboard-user-settings.png ├── dashboard.png ├── docs.png ├── github-social-preview.png ├── github-social-preview.svg └── login.png ├── nextjs+fastapi-template-dark-mode.png ├── nextjs+fastapi-template.png ├── requirements.txt ├── scripts ├── build-push.sh ├── build.sh ├── deploy.sh ├── test-local.sh └── test.sh └── supabase ├── .gitignore ├── config.toml └── seed.sql /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | commit-message: 9 | prefix: ⬆ 10 | # Python 11 | - package-ecosystem: "pip" 12 | directory: "/" 13 | schedule: 14 | interval: "daily" 15 | commit-message: 16 | prefix: ⬆ 17 | -------------------------------------------------------------------------------- /.github/workflows/db-migration.yml: -------------------------------------------------------------------------------- 1 | name: Alembic DB Migration and Load Initial Data 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | alembic-migration: 10 | runs-on: ubuntu-latest 11 | 12 | env: 13 | POSTGRES_DB: ${{ secrets.POSTGRES_DB }} 14 | POSTGRES_USER: ${{ secrets.POSTGRES_USER }} 15 | POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} 16 | POSTGRES_SERVER: ${{ secrets.POSTGRES_SERVER }} 17 | POSTGRES_PORT: ${{ secrets.POSTGRES_PORT }} 18 | FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }} 19 | FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }} 20 | ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} 21 | PROJECT_NAME: ${{ vars.PROJECT_NAME }} 22 | GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} 23 | GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} 24 | GOOGLE_REDIRECT_URI: ${{ secrets.GOOGLE_REDIRECT_URI }} 25 | GH_CLIENT_ID: ${{ secrets.GH_CLIENT_ID }} 26 | GH_CLIENT_SECRET: ${{ secrets.GH_CLIENT_SECRET }} 27 | OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }} 28 | 29 | steps: 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Set up Python 34 | uses: actions/setup-python@v3 35 | with: 36 | python-version: "3.11" 37 | 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install -r requirements.txt 42 | 43 | - name: Start the DB 44 | working-directory: backend/src 45 | run: | 46 | python backend_pre_start.py 47 | 48 | - name: Run Alembic migrations 49 | working-directory: backend/src/app 50 | run: | 51 | alembic upgrade head 52 | 53 | - name: Create initial data in the DB 54 | working-directory: backend/src 55 | run: | 56 | python initial_data.py 57 | -------------------------------------------------------------------------------- /.github/workflows/fastapi-to-gcr.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Fastapi backend to Cloud Run 2 | 3 | env: 4 | SERVICE_NAME: ai-engineer-template-backend 5 | PROJECT_ID: ai-engineer-template 6 | DOCKER_IMAGE_URL: australia-southeast1-docker.pkg.dev/ai-engineer-template/fastapi-backend/fastapi-backend 7 | USERS_OPEN_REGISTRATION: True 8 | on: 9 | push: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | dockerize-and-deploy: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v3 20 | 21 | - name: Google Cloud Auth 22 | uses: "google-github-actions/auth@v2" 23 | with: 24 | credentials_json: "${{ secrets.GCP_SA_KEY }}" 25 | project_id: ${{ env.PROJECT_ID }} 26 | 27 | - name: Set up Cloud SDK 28 | uses: "google-github-actions/setup-gcloud@v2.1.1" 29 | 30 | - name: Configure Docker 31 | run: | 32 | gcloud auth configure-docker australia-southeast1-docker.pkg.dev 33 | 34 | - name: Build and Push Docker Image 35 | working-directory: backend/src 36 | run: | 37 | docker build -t ${{ env.DOCKER_IMAGE_URL }}:latest -f Dockerfile . 38 | docker push ${{ env.DOCKER_IMAGE_URL }}:latest 39 | 40 | - name: Deploy to Cloud Run 41 | run: | 42 | echo SERVICE_NAME $SERVICE_NAME 43 | gcloud run deploy $SERVICE_NAME \ 44 | --image ${{ env.DOCKER_IMAGE_URL }}:latest \ 45 | --platform managed \ 46 | --set-env-vars POSTGRES_SERVER=${{ secrets.POSTGRES_SERVER }},POSTGRES_PORT=${{ secrets.POSTGRES_PORT }},POSTGRES_DB=${{ secrets.POSTGRES_DB }},POSTGRES_USER=${{ secrets.POSTGRES_USER }},POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }},ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }},PROJECT_NAME=${{ env.PROJECT_ID }},FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }},FIRST_SUPERUSER_PASSWORD=${{ secrets.FIRST_SUPERUSER_PASSWORD }},USERS_OPEN_REGISTRATION=${{ env.USERS_OPEN_REGISTRATION }},GOOGLE_CLIENT_ID=${{ secrets.GOOGLE_CLIENT_ID }},GOOGLE_CLIENT_SECRET=${{ secrets.GOOGLE_CLIENT_SECRET }},GOOGLE_REDIRECT_URI=${{ secrets.GOOGLE_REDIRECT_URI }},GH_CLIENT_ID=${{ secrets.GH_CLIENT_ID }},GH_CLIENT_SECRET=${{ secrets.GH_CLIENT_SECRET }},OAUTH_REDIRECT_URI=${{ secrets.OAUTH_REDIRECT_URI }} \ 47 | --region australia-southeast1 \ 48 | --allow-unauthenticated 49 | -------------------------------------------------------------------------------- /.github/workflows/nextjs-preview.yml: -------------------------------------------------------------------------------- 1 | name: Vercel Preview Deployment 2 | env: 3 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 4 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 5 | on: 6 | push: 7 | branches-ignore: 8 | - main 9 | jobs: 10 | Deploy-Preview: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Install Vercel CLI 15 | run: npm install --global vercel@canary 16 | - name: Pull Vercel Environment Information 17 | run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }} 18 | working-directory: frontend 19 | - name: Build Project Artifacts 20 | run: vercel build --token=${{ secrets.VERCEL_TOKEN }} 21 | working-directory: frontend 22 | - name: Deploy Project Artifacts to Vercel 23 | run: vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }} 24 | working-directory: frontend 25 | -------------------------------------------------------------------------------- /.github/workflows/nextjs-production.yml: -------------------------------------------------------------------------------- 1 | name: Vercel Production Deployment 2 | env: 3 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} 4 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 5 | on: 6 | push: 7 | branches: 8 | - main 9 | jobs: 10 | Deploy-Production: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Install Vercel CLI 15 | run: npm install --global vercel@canary 16 | - name: Pull Vercel Environment Information 17 | run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }} 18 | working-directory: frontend 19 | - name: Build Project Artifacts 20 | run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }} 21 | working-directory: frontend 22 | - name: Deploy Project Artifacts to Vercel 23 | run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} 24 | working-directory: frontend 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .env 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.4.0 6 | hooks: 7 | - id: check-added-large-files 8 | - id: check-toml 9 | - id: check-yaml 10 | args: 11 | - --unsafe 12 | - id: end-of-file-fixer 13 | exclude: ^frontend/src/client/.* 14 | - id: trailing-whitespace 15 | exclude: ^frontend/src/client/.* 16 | - repo: https://github.com/charliermarsh/ruff-pre-commit 17 | rev: v0.2.2 18 | hooks: 19 | - id: ruff 20 | args: 21 | - --fix 22 | - id: ruff-format 23 | 24 | ci: 25 | autofix_commit_msg: 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks 26 | autoupdate_commit_msg: ⬆ [pre-commit.ci] pre-commit autoupdate 27 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug FastAPI Project backend: Python Debugger", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "module": "uvicorn", 12 | "args": [ 13 | "app.main:app", 14 | "--reload" 15 | ], 16 | "cwd": "${workspaceFolder}/backend", 17 | "jinja": true, 18 | "envFile": "${workspaceFolder}/.env", 19 | }, 20 | { 21 | "type": "chrome", 22 | "request": "launch", 23 | "name": "Debug Frontend: Launch Chrome against http://localhost:5173", 24 | "url": "http://localhost:5173", 25 | "webRoot": "${workspaceFolder}/frontend" 26 | }, 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sebastián Ramírez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/architecture.png -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | app.egg-info 4 | *.pyc 5 | .mypy_cache 6 | .coverage 7 | htmlcov 8 | .venv 9 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | app.egg-info 3 | *.pyc 4 | .mypy_cache 5 | .coverage 6 | htmlcov 7 | .cache 8 | .venv 9 | *cdk.out 10 | *service.yml 11 | -------------------------------------------------------------------------------- /backend/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | fastapi-backend: 3 | build: src 4 | ports: 5 | - "8080:8000" 6 | volumes: 7 | - ./src:/src 8 | -------------------------------------------------------------------------------- /backend/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "app" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Admin "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.10" 9 | uvicorn = {extras = ["standard"], version = "^0.24.0.post1"} 10 | fastapi = "^0.109.1" 11 | python-multipart = "^0.0.7" 12 | email-validator = "^2.1.0.post1" 13 | passlib = {extras = ["bcrypt"], version = "^1.7.4"} 14 | tenacity = "^8.2.3" 15 | pydantic = ">2.0" 16 | emails = "^0.6" 17 | 18 | gunicorn = "^22.0.0" 19 | jinja2 = "^3.1.2" 20 | python-jose = {extras = ["cryptography"], version = "^3.3.0"} 21 | httpx = "^0.25.1" 22 | psycopg = {extras = ["binary"], version = "^3.1.13"} 23 | sqlmodel = "^0.0.16" 24 | # Pin bcrypt until passlib supports the latest 25 | bcrypt = "4.0.1" 26 | pydantic-settings = "^2.2.1" 27 | sentry-sdk = {extras = ["fastapi"], version = "^1.40.6"} 28 | openai = "^1.37.0" 29 | python-dotenv = "^1.0.1" 30 | cryptography = "^43.0.0" 31 | mangum = "^0.19.0" 32 | alembic = "^1.13.3" 33 | psycopg2-binary = "^2.9.9" 34 | 35 | [tool.poetry.group.dev.dependencies] 36 | pytest = "^7.4.3" 37 | mypy = "^1.8.0" 38 | ruff = "^0.2.2" 39 | pre-commit = "^3.6.2" 40 | types-python-jose = "^3.3.4.20240106" 41 | types-passlib = "^1.7.7.20240106" 42 | coverage = "^7.4.3" 43 | 44 | [build-system] 45 | requires = ["poetry>=0.12"] 46 | build-backend = "poetry.masonry.api" 47 | 48 | [tool.mypy] 49 | strict = true 50 | exclude = ["venv", ".venv", "alembic"] 51 | 52 | [tool.ruff] 53 | target-version = "py310" 54 | exclude = ["alembic"] 55 | 56 | [tool.ruff.lint] 57 | select = [ 58 | "E", # pycodestyle errors 59 | "W", # pycodestyle warnings 60 | "F", # pyflakes 61 | "I", # isort 62 | "B", # flake8-bugbear 63 | "C4", # flake8-comprehensions 64 | "UP", # pyupgrade 65 | "ARG001", # unused arguments in functions 66 | ] 67 | ignore = [ 68 | "E501", # line too long, handled by black 69 | "B008", # do not perform function calls in argument defaults 70 | "W191", # indentation contains tabs 71 | "B904", # Allow raising exceptions without from e, for HTTPException 72 | ] 73 | 74 | [tool.ruff.lint.pyupgrade] 75 | # Preserve types, even if a file imports `from __future__ import annotations`. 76 | keep-runtime-typing = true 77 | -------------------------------------------------------------------------------- /backend/scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | ruff check app scripts --fix 5 | ruff format app scripts 6 | -------------------------------------------------------------------------------- /backend/scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | mypy app 7 | ruff check app 8 | ruff format app --check 9 | -------------------------------------------------------------------------------- /backend/scripts/prestart.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | cd src 4 | # Load environment variables 5 | export $(grep -v '^#' .env | xargs) 6 | 7 | # Let the DB start 8 | python backend_pre_start.py 9 | 10 | cd app 11 | # Run migrations 12 | alembic upgrade head 13 | 14 | cd .. 15 | # Create initial data in DB 16 | python initial_data.py 17 | -------------------------------------------------------------------------------- /backend/scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | coverage run --source=app -m pytest 7 | coverage report --show-missing 8 | coverage html --title "${@-coverage}" 9 | -------------------------------------------------------------------------------- /backend/scripts/tests-start.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | set -e 3 | set -x 4 | 5 | python /app/app/tests_pre_start.py 6 | 7 | bash ./scripts/test.sh "$@" 8 | -------------------------------------------------------------------------------- /backend/src/.env.example: -------------------------------------------------------------------------------- 1 | # Domain 2 | # This would be set to the production domain with an env var on deployment 3 | DOMAIN=localhost 4 | 5 | # Environment: local, staging, production 6 | ENVIRONMENT=local 7 | 8 | PROJECT_NAME="Full stack AI engineer template" 9 | STACK_NAME=full-stack-ai-engineer-template 10 | 11 | # Backend 12 | BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" 13 | SECRET_KEY= 14 | FIRST_SUPERUSER= 15 | FIRST_SUPERUSER_PASSWORD= 16 | USERS_OPEN_REGISTRATION=False 17 | ENCRYPTION_KEY= 18 | 19 | # Emails 20 | SMTP_HOST= 21 | SMTP_USER= 22 | SMTP_PASSWORD= 23 | EMAILS_FROM_EMAIL=info@example.com 24 | SMTP_TLS=True 25 | SMTP_SSL=False 26 | SMTP_PORT=587 27 | 28 | # Postgres 29 | POSTGRES_SERVER=localhost 30 | POSTGRES_PORT=54322 31 | POSTGRES_DB=db 32 | POSTGRES_USER= 33 | POSTGRES_PASSWORD= 34 | 35 | SENTRY_DSN= 36 | 37 | # Configure these with your own Docker registry images 38 | DOCKER_IMAGE_BACKEND=backend 39 | DOCKER_IMAGE_FRONTEND=frontend 40 | 41 | # Google auth 42 | GOOGLE_CLIENT_ID= 43 | GOOGLE_CLIENT_SECRET= 44 | GOOGLE_REDIRECT_URI= 45 | 46 | # Frontend 47 | OAUTH_REDIRECT_URI=http://localhost:3000/dashboard 48 | -------------------------------------------------------------------------------- /backend/src/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | WORKDIR /src 4 | 5 | COPY ./requirements.txt /src/requirements.txt 6 | RUN pip install --no-cache-dir --upgrade -r /src/requirements.txt 7 | 8 | COPY ./main.py /src/main.py 9 | COPY ./app /src/app 10 | 11 | CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"] 12 | -------------------------------------------------------------------------------- /backend/src/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/__init__.py -------------------------------------------------------------------------------- /backend/src/app/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. 2 | -------------------------------------------------------------------------------- /backend/src/app/alembic/env.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import engine_from_config, pool 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | 15 | # add your model's MetaData object here 16 | # for 'autogenerate' support 17 | # from myapp import mymodel 18 | # target_metadata = mymodel.Base.metadata 19 | # target_metadata = None 20 | 21 | from models import SQLModel # noqa 22 | 23 | target_metadata = SQLModel.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def get_url(): 32 | user = os.getenv("POSTGRES_USER", "postgres") 33 | password = os.getenv("POSTGRES_PASSWORD", "") 34 | server = os.getenv("POSTGRES_SERVER", "db") 35 | port = os.getenv("POSTGRES_PORT", "5432") 36 | db = os.getenv("POSTGRES_DB", "app") 37 | # return f"postgresql+psycopg2://{user}:{password}@{server}:{port}/{db}" 38 | return f"postgresql://{user}:{password}@{server}:{port}/{db}" 39 | # return "postgresql://postgres:postgres@localhost:54322/postgres" 40 | 41 | 42 | def run_migrations_offline(): 43 | """Run migrations in 'offline' mode. 44 | 45 | This configures the context with just a URL 46 | and not an Engine, though an Engine is acceptable 47 | here as well. By skipping the Engine creation 48 | we don't even need a DBAPI to be available. 49 | 50 | Calls to context.execute() here emit the given string to the 51 | script output. 52 | 53 | """ 54 | url = get_url() 55 | context.configure( 56 | url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True 57 | ) 58 | 59 | with context.begin_transaction(): 60 | context.run_migrations() 61 | 62 | 63 | def run_migrations_online(): 64 | """Run migrations in 'online' mode. 65 | 66 | In this scenario we need to create an Engine 67 | and associate a connection with the context. 68 | 69 | """ 70 | configuration = config.get_section(config.config_ini_section) 71 | configuration["sqlalchemy.url"] = get_url() 72 | connectable = engine_from_config( 73 | configuration, 74 | prefix="sqlalchemy.", 75 | poolclass=pool.NullPool, 76 | ) 77 | 78 | with connectable.connect() as connection: 79 | context.configure( 80 | connection=connection, target_metadata=target_metadata, compare_type=True 81 | ) 82 | 83 | with context.begin_transaction(): 84 | context.run_migrations() 85 | 86 | 87 | if context.is_offline_mode(): 88 | run_migrations_offline() 89 | else: 90 | run_migrations_online() 91 | -------------------------------------------------------------------------------- /backend/src/app/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /backend/src/app/alembic/versions/0bab391507d0_add_oauth_provider_to_user_table.py: -------------------------------------------------------------------------------- 1 | """add oauth provider to user table 2 | 3 | Revision ID: 0bab391507d0 4 | Revises: ae5a34f2895e 5 | Create Date: 2024-10-21 18:13:27.236901 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | import sqlmodel 13 | 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = '0bab391507d0' 17 | down_revision: Union[str, None] = 'ae5a34f2895e' 18 | branch_labels: Union[str, Sequence[str], None] = None 19 | depends_on: Union[str, Sequence[str], None] = None 20 | 21 | 22 | def upgrade() -> None: 23 | # ### commands auto generated by Alembic - please adjust! ### 24 | op.add_column('user', sa.Column('provider', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) 25 | op.alter_column('user', 'hashed_password', 26 | existing_type=sa.VARCHAR(), 27 | nullable=True) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.alter_column('user', 'hashed_password', 34 | existing_type=sa.VARCHAR(), 35 | nullable=False) 36 | op.drop_column('user', 'provider') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /backend/src/app/alembic/versions/ae5a34f2895e_add_chat_table.py: -------------------------------------------------------------------------------- 1 | """add chat table 2 | 3 | Revision ID: ae5a34f2895e 4 | Revises: da90f41ddaa1 5 | Create Date: 2024-08-10 06:49:41.878641 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel.sql.sqltypes 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "ae5a34f2895e" 15 | down_revision = "da90f41ddaa1" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "chat", 24 | sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 25 | sa.Column("chat_config_id", sa.Integer(), nullable=False), 26 | sa.Column("messages", sa.JSON(), nullable=True), 27 | sa.Column( 28 | "created_at", 29 | sa.DateTime(timezone=True), 30 | server_default=sa.text("now()"), 31 | nullable=True, 32 | ), 33 | sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), 34 | sa.Column("owner_id", sa.Integer(), nullable=False), 35 | sa.ForeignKeyConstraint( 36 | ["chat_config_id"], 37 | ["chatconfig.id"], 38 | ), 39 | sa.ForeignKeyConstraint( 40 | ["owner_id"], 41 | ["user.id"], 42 | ), 43 | sa.PrimaryKeyConstraint("id"), 44 | ) 45 | # ### end Alembic commands ### 46 | 47 | 48 | def downgrade(): 49 | # ### commands auto generated by Alembic - please adjust! ### 50 | op.drop_table("chat") 51 | # ### end Alembic commands ### 52 | -------------------------------------------------------------------------------- /backend/src/app/alembic/versions/da90f41ddaa1_add_chatconfig_table.py: -------------------------------------------------------------------------------- 1 | """add chatconfig table 2 | 3 | Revision ID: da90f41ddaa1 4 | Revises: e2412789c190 5 | Create Date: 2024-08-06 20:49:17.213963 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | import sqlmodel.sql.sqltypes 11 | 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "da90f41ddaa1" 15 | down_revision = "e2412789c190" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | # ### commands auto generated by Alembic - please adjust! ### 22 | op.create_table( 23 | "chatconfig", 24 | sa.Column("model", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 25 | sa.Column("temperature", sa.Float(), nullable=False), 26 | sa.Column("top_p", sa.Float(), nullable=True), 27 | sa.Column("top_k", sa.Integer(), nullable=True), 28 | sa.Column("system_message", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 29 | sa.Column("id", sa.Integer(), nullable=False), 30 | sa.Column("owner_id", sa.Integer(), nullable=False), 31 | sa.Column( 32 | "api_key_encrypted", sqlmodel.sql.sqltypes.AutoString(), nullable=True 33 | ), 34 | sa.ForeignKeyConstraint( 35 | ["owner_id"], 36 | ["user.id"], 37 | ), 38 | sa.PrimaryKeyConstraint("id"), 39 | ) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade(): 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.drop_table("chatconfig") 46 | # ### end Alembic commands ### 47 | -------------------------------------------------------------------------------- /backend/src/app/alembic/versions/e2412789c190_initialize_models.py: -------------------------------------------------------------------------------- 1 | """Initialize models 2 | 3 | Revision ID: e2412789c190 4 | Revises: 5 | Create Date: 2023-11-24 22:55:43.195942 6 | 7 | """ 8 | import sqlalchemy as sa 9 | import sqlmodel.sql.sqltypes 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "e2412789c190" 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | "user", 23 | sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 24 | sa.Column("is_active", sa.Boolean(), nullable=False), 25 | sa.Column("is_superuser", sa.Boolean(), nullable=False), 26 | sa.Column("full_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 27 | sa.Column("id", sa.Integer(), nullable=False), 28 | sa.Column( 29 | "hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False 30 | ), 31 | sa.PrimaryKeyConstraint("id"), 32 | ) 33 | op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) 34 | op.create_table( 35 | "item", 36 | sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), 37 | sa.Column("id", sa.Integer(), nullable=False), 38 | sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), 39 | sa.Column("owner_id", sa.Integer(), nullable=False), 40 | sa.ForeignKeyConstraint( 41 | ["owner_id"], 42 | ["user.id"], 43 | ), 44 | sa.PrimaryKeyConstraint("id"), 45 | ) 46 | # ### end Alembic commands ### 47 | 48 | 49 | def downgrade(): 50 | # ### commands auto generated by Alembic - please adjust! ### 51 | op.drop_table("item") 52 | op.drop_index(op.f("ix_user_email"), table_name="user") 53 | op.drop_table("user") 54 | # ### end Alembic commands ### 55 | -------------------------------------------------------------------------------- /backend/src/app/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/api/__init__.py -------------------------------------------------------------------------------- /backend/src/app/api/deps.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | from typing import Annotated 3 | 4 | from fastapi import Depends, HTTPException, status 5 | from fastapi.security import OAuth2PasswordBearer 6 | from jose import JWTError, jwt 7 | from pydantic import ValidationError 8 | from sqlmodel import Session 9 | 10 | from app.core import security 11 | from app.core.config import settings 12 | from app.core.db import engine 13 | from app.models import TokenPayload, User 14 | 15 | reusable_oauth2 = OAuth2PasswordBearer( 16 | tokenUrl=f"{settings.API_V1_STR}/login/access-token" 17 | ) 18 | 19 | 20 | def get_db() -> Generator[Session, None, None]: 21 | with Session(engine) as session: 22 | yield session 23 | 24 | 25 | SessionDep = Annotated[Session, Depends(get_db)] 26 | TokenDep = Annotated[str, Depends(reusable_oauth2)] 27 | 28 | 29 | def get_current_user(session: SessionDep, token: TokenDep) -> User: 30 | try: 31 | payload = jwt.decode( 32 | token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] 33 | ) 34 | token_data = TokenPayload(**payload) 35 | except (JWTError, ValidationError): 36 | raise HTTPException( 37 | status_code=status.HTTP_403_FORBIDDEN, 38 | detail="Could not validate credentials", 39 | ) 40 | user = session.get(User, token_data.sub) 41 | if not user: 42 | raise HTTPException(status_code=404, detail="User not found") 43 | if not user.is_active: 44 | raise HTTPException(status_code=400, detail="Inactive user") 45 | return user 46 | 47 | 48 | CurrentUser = Annotated[User, Depends(get_current_user)] 49 | 50 | 51 | def get_current_active_superuser(current_user: CurrentUser) -> User: 52 | if not current_user.is_superuser: 53 | raise HTTPException( 54 | status_code=403, detail="The user doesn't have enough privileges" 55 | ) 56 | return current_user 57 | -------------------------------------------------------------------------------- /backend/src/app/api/main_route.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter 2 | 3 | from app.api.routes import chat, items, login, users, utils 4 | 5 | api_router = APIRouter() 6 | api_router.include_router(login.router, tags=["login"]) 7 | api_router.include_router(users.router, prefix="/users", tags=["users"]) 8 | api_router.include_router(utils.router, prefix="/utils", tags=["utils"]) 9 | api_router.include_router(items.router, prefix="/items", tags=["items"]) 10 | api_router.include_router(chat.router, prefix="/chat", tags=["chat"]) 11 | -------------------------------------------------------------------------------- /backend/src/app/api/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/api/routes/__init__.py -------------------------------------------------------------------------------- /backend/src/app/api/routes/items.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from fastapi import APIRouter, HTTPException 4 | from sqlmodel import func, select 5 | 6 | from app.api.deps import CurrentUser, SessionDep 7 | from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/", response_model=ItemsPublic) 13 | def read_items( 14 | session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 15 | ) -> Any: 16 | """ 17 | Retrieve items. 18 | """ 19 | 20 | if current_user.is_superuser: 21 | count_statement = select(func.count()).select_from(Item) 22 | count = session.exec(count_statement).one() 23 | statement = select(Item).offset(skip).limit(limit) 24 | items = session.exec(statement).all() 25 | else: 26 | count_statement = ( 27 | select(func.count()) 28 | .select_from(Item) 29 | .where(Item.owner_id == current_user.id) 30 | ) 31 | count = session.exec(count_statement).one() 32 | statement = ( 33 | select(Item) 34 | .where(Item.owner_id == current_user.id) 35 | .offset(skip) 36 | .limit(limit) 37 | ) 38 | items = session.exec(statement).all() 39 | 40 | return ItemsPublic(data=items, count=count) 41 | 42 | 43 | @router.get("/{id}", response_model=ItemPublic) 44 | def read_item(session: SessionDep, current_user: CurrentUser, id: int) -> Any: 45 | """ 46 | Get item by ID. 47 | """ 48 | item = session.get(Item, id) 49 | if not item: 50 | raise HTTPException(status_code=404, detail="Item not found") 51 | if not current_user.is_superuser and (item.owner_id != current_user.id): 52 | raise HTTPException(status_code=400, detail="Not enough permissions") 53 | return item 54 | 55 | 56 | @router.post("/", response_model=ItemPublic) 57 | def create_item( 58 | *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate 59 | ) -> Any: 60 | """ 61 | Create new item. 62 | """ 63 | item = Item.model_validate(item_in, update={"owner_id": current_user.id}) 64 | session.add(item) 65 | session.commit() 66 | session.refresh(item) 67 | return item 68 | 69 | 70 | @router.put("/{id}", response_model=ItemPublic) 71 | def update_item( 72 | *, session: SessionDep, current_user: CurrentUser, id: int, item_in: ItemUpdate 73 | ) -> Any: 74 | """ 75 | Update an item. 76 | """ 77 | item = session.get(Item, id) 78 | if not item: 79 | raise HTTPException(status_code=404, detail="Item not found") 80 | if not current_user.is_superuser and (item.owner_id != current_user.id): 81 | raise HTTPException(status_code=400, detail="Not enough permissions") 82 | update_dict = item_in.model_dump(exclude_unset=True) 83 | item.sqlmodel_update(update_dict) 84 | session.add(item) 85 | session.commit() 86 | session.refresh(item) 87 | return item 88 | 89 | 90 | @router.delete("/{id}") 91 | def delete_item(session: SessionDep, current_user: CurrentUser, id: int) -> Message: 92 | """ 93 | Delete an item. 94 | """ 95 | item = session.get(Item, id) 96 | if not item: 97 | raise HTTPException(status_code=404, detail="Item not found") 98 | if not current_user.is_superuser and (item.owner_id != current_user.id): 99 | raise HTTPException(status_code=400, detail="Not enough permissions") 100 | session.delete(item) 101 | session.commit() 102 | return Message(message="Item deleted successfully") 103 | -------------------------------------------------------------------------------- /backend/src/app/api/routes/shop.py: -------------------------------------------------------------------------------- 1 | # shop.py 2 | from typing import Any 3 | 4 | import requests 5 | from bs4 import BeautifulSoup 6 | from fastapi import APIRouter, HTTPException 7 | from pydantic import BaseModel 8 | 9 | router = APIRouter() 10 | 11 | 12 | class GoogleShoppingRequest(BaseModel): 13 | query: str 14 | location: str 15 | hl: str 16 | gl: str 17 | 18 | 19 | class ScrapedData(BaseModel): 20 | title: str 21 | description: str 22 | links: list[str] 23 | 24 | 25 | @router.get("/google_shopping") 26 | def google_shopping_search(request: GoogleShoppingRequest) -> dict[str, Any]: 27 | """ 28 | Fetches products from Google Shopping using SerpApi. 29 | """ 30 | params = { 31 | "engine": "google_shopping", 32 | "q": request.query, 33 | "location": request.location, 34 | "hl": request.hl, 35 | "gl": request.gl, 36 | "api_key": "YOUR_SERPAPI_KEY_HERE", 37 | } 38 | response = requests.get("https://serpapi.com/search", params=params) 39 | if response.status_code == 200: 40 | return response.json() 41 | else: 42 | raise HTTPException( 43 | status_code=response.status_code, detail="Failed to fetch data from SerpApi" 44 | ) 45 | 46 | 47 | @router.get("/scrape_anaconda") 48 | def scrape_anaconda() -> ScrapedData: 49 | """ 50 | Scrapes the Anaconda store page and extracts key components. 51 | """ 52 | url = "https://www.cashrewards.com.au/store/anaconda" 53 | response = requests.get(url) 54 | if response.status_code == 200: 55 | soup = BeautifulSoup(response.content, "html.parser") 56 | title = soup.find("title").text 57 | description = soup.find("meta", attrs={"name": "description"})["content"] 58 | links = [a["href"] for a in soup.find_all("a", href=True)] 59 | return ScrapedData(title=title, description=description, links=links) 60 | else: 61 | raise HTTPException( 62 | status_code=response.status_code, detail="Failed to scrape Anaconda page" 63 | ) 64 | -------------------------------------------------------------------------------- /backend/src/app/api/routes/utils.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends 2 | from pydantic.networks import EmailStr 3 | 4 | from app.api.deps import get_current_active_superuser 5 | from app.models import Message 6 | from app.utils import generate_test_email, send_email 7 | 8 | router = APIRouter() 9 | 10 | 11 | @router.post( 12 | "/test-email/", 13 | dependencies=[Depends(get_current_active_superuser)], 14 | status_code=201, 15 | ) 16 | def test_email(email_to: EmailStr) -> Message: 17 | """ 18 | Test emails. 19 | """ 20 | email_data = generate_test_email(email_to=email_to) 21 | send_email( 22 | email_to=email_to, 23 | subject=email_data.subject, 24 | html_content=email_data.html_content, 25 | ) 26 | return Message(message="Test email sent") 27 | -------------------------------------------------------------------------------- /backend/src/app/api/utils.py: -------------------------------------------------------------------------------- 1 | def get_streamed_response(response): 2 | for chunk in response: 3 | if chunk.choices[0].delta.content is not None: 4 | yield chunk.choices[0].delta.content 5 | -------------------------------------------------------------------------------- /backend/src/app/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/core/__init__.py -------------------------------------------------------------------------------- /backend/src/app/core/db.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session, create_engine, select 2 | 3 | from app import crud 4 | from app.core.config import settings 5 | from app.models import User, UserCreate 6 | 7 | engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) 8 | 9 | 10 | # make sure all SQLModel models are imported (app.models) before initializing DB 11 | # otherwise, SQLModel might fail to initialize relationships properly 12 | # for more details: https://github.com/tiangolo/full-stack-fastapi-template/issues/28 13 | 14 | 15 | def init_db(session: Session) -> None: 16 | # Tables should be created with Alembic migrations 17 | # But if you don't want to use migrations, create 18 | # the tables un-commenting the next lines 19 | # from sqlmodel import SQLModel 20 | 21 | # from app.core.engine import engine 22 | # This works because the models are already imported and registered from app.models 23 | # SQLModel.metadata.create_all(engine) 24 | 25 | user = session.exec( 26 | select(User).where(User.email == settings.FIRST_SUPERUSER) 27 | ).first() 28 | if not user: 29 | user_in = UserCreate( 30 | email=settings.FIRST_SUPERUSER, 31 | password=settings.FIRST_SUPERUSER_PASSWORD, 32 | is_superuser=True, 33 | ) 34 | user = crud.create_user(session=session, user_create=user_in) 35 | -------------------------------------------------------------------------------- /backend/src/app/core/security.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Any 3 | 4 | from cryptography.fernet import Fernet 5 | from jose import jwt 6 | from passlib.context import CryptContext 7 | 8 | from app.core.config import settings 9 | 10 | # Initialize Fernet with the key 11 | cipher_suite = Fernet(settings.ENCRYPTION_KEY) 12 | 13 | pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 14 | 15 | 16 | ALGORITHM = "HS256" 17 | 18 | 19 | def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: 20 | expire = datetime.utcnow() + expires_delta 21 | to_encode = {"exp": expire, "sub": str(subject)} 22 | encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) 23 | return encoded_jwt 24 | 25 | 26 | def verify_password(plain_password: str, hashed_password: str) -> bool: 27 | return pwd_context.verify(plain_password, hashed_password) 28 | 29 | 30 | def get_password_hash(password: str) -> str: 31 | return pwd_context.hash(password) 32 | 33 | 34 | def encrypt_api_key(api_key: str) -> str: 35 | return cipher_suite.encrypt(api_key.encode()).decode() 36 | 37 | 38 | def decrypt_api_key(encrypted_api_key: str) -> str: 39 | return cipher_suite.decrypt(encrypted_api_key.encode()).decode() 40 | -------------------------------------------------------------------------------- /backend/src/app/crud.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from sqlmodel import Session, select 4 | 5 | from app.core.security import get_password_hash, verify_password 6 | from app.models import Item, ItemCreate, User, UserCreate, UserCreateOauth, UserUpdate 7 | 8 | 9 | def create_user(*, session: Session, user_create: UserCreate) -> User: 10 | db_obj = User.model_validate( 11 | user_create, update={"hashed_password": get_password_hash(user_create.password)} 12 | ) 13 | session.add(db_obj) 14 | session.commit() 15 | session.refresh(db_obj) 16 | return db_obj 17 | 18 | 19 | def create_user_oauth(*, session: Session, user_create: UserCreateOauth) -> User: 20 | db_obj = User.model_validate(user_create) 21 | session.add(db_obj) 22 | session.commit() 23 | session.refresh(db_obj) 24 | return db_obj 25 | 26 | 27 | def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: 28 | user_data = user_in.model_dump(exclude_unset=True) 29 | extra_data = {} 30 | if "password" in user_data: 31 | password = user_data["password"] 32 | hashed_password = get_password_hash(password) 33 | extra_data["hashed_password"] = hashed_password 34 | db_user.sqlmodel_update(user_data, update=extra_data) 35 | session.add(db_user) 36 | session.commit() 37 | session.refresh(db_user) 38 | return db_user 39 | 40 | 41 | def get_user_by_email(*, session: Session, email: str) -> User | None: 42 | statement = select(User).where(User.email == email) 43 | session_user = session.exec(statement).first() 44 | return session_user 45 | 46 | 47 | def authenticate(*, session: Session, email: str, password: str) -> User | None: 48 | db_user = get_user_by_email(session=session, email=email) 49 | if not db_user: 50 | return None 51 | if not verify_password(password, db_user.hashed_password): 52 | return None 53 | return db_user 54 | 55 | 56 | def create_item(*, session: Session, item_in: ItemCreate, owner_id: int) -> Item: 57 | db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) 58 | session.add(db_item) 59 | session.commit() 60 | session.refresh(db_item) 61 | return db_item 62 | -------------------------------------------------------------------------------- /backend/src/app/email-templates/src/new_account.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ project_name }} - New Account 6 | Welcome to your new account! 7 | Here are your account details: 8 | Username: {{ username }} 9 | Password: {{ password }} 10 | Go to Dashboard 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /backend/src/app/email-templates/src/reset_password.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ project_name }} - Password Recovery 6 | Hello {{ username }} 7 | We've received a request to reset your password. You can do it by clicking the button below: 8 | Reset password 9 | Or copy and paste the following link into your browser: 10 | {{ link }} 11 | This password will expire in {{ valid_hours }} hours. 12 | 13 | If you didn't request a password recovery you can disregard this email. 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /backend/src/app/email-templates/src/test_email.mjml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ project_name }} 6 | Test email for: {{ email }} 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /backend/src/app/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/tests/__init__.py -------------------------------------------------------------------------------- /backend/src/app/tests/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/tests/api/__init__.py -------------------------------------------------------------------------------- /backend/src/app/tests/api/routes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/tests/api/routes/__init__.py -------------------------------------------------------------------------------- /backend/src/app/tests/api/routes/test_login.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from fastapi.testclient import TestClient 4 | from sqlmodel import Session, select 5 | 6 | from app.core.config import settings 7 | from app.core.security import verify_password 8 | from app.models import User 9 | from app.utils import generate_password_reset_token 10 | 11 | 12 | def test_get_access_token(client: TestClient) -> None: 13 | login_data = { 14 | "username": settings.FIRST_SUPERUSER, 15 | "password": settings.FIRST_SUPERUSER_PASSWORD, 16 | } 17 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 18 | tokens = r.json() 19 | assert r.status_code == 200 20 | assert "access_token" in tokens 21 | assert tokens["access_token"] 22 | 23 | 24 | def test_get_access_token_incorrect_password(client: TestClient) -> None: 25 | login_data = { 26 | "username": settings.FIRST_SUPERUSER, 27 | "password": "incorrect", 28 | } 29 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 30 | assert r.status_code == 400 31 | 32 | 33 | def test_use_access_token( 34 | client: TestClient, superuser_token_headers: dict[str, str] 35 | ) -> None: 36 | r = client.post( 37 | f"{settings.API_V1_STR}/login/test-token", 38 | headers=superuser_token_headers, 39 | ) 40 | result = r.json() 41 | assert r.status_code == 200 42 | assert "email" in result 43 | 44 | 45 | def test_recovery_password( 46 | client: TestClient, normal_user_token_headers: dict[str, str] 47 | ) -> None: 48 | with ( 49 | patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"), 50 | patch("app.core.config.settings.SMTP_USER", "admin@example.com"), 51 | ): 52 | email = "test@example.com" 53 | r = client.post( 54 | f"{settings.API_V1_STR}/password-recovery/{email}", 55 | headers=normal_user_token_headers, 56 | ) 57 | assert r.status_code == 200 58 | assert r.json() == {"message": "Password recovery email sent"} 59 | 60 | 61 | def test_recovery_password_user_not_exits( 62 | client: TestClient, normal_user_token_headers: dict[str, str] 63 | ) -> None: 64 | email = "jVgQr@example.com" 65 | r = client.post( 66 | f"{settings.API_V1_STR}/password-recovery/{email}", 67 | headers=normal_user_token_headers, 68 | ) 69 | assert r.status_code == 404 70 | 71 | 72 | def test_reset_password( 73 | client: TestClient, superuser_token_headers: dict[str, str], db: Session 74 | ) -> None: 75 | token = generate_password_reset_token(email=settings.FIRST_SUPERUSER) 76 | data = {"new_password": "changethis", "token": token} 77 | r = client.post( 78 | f"{settings.API_V1_STR}/reset-password/", 79 | headers=superuser_token_headers, 80 | json=data, 81 | ) 82 | assert r.status_code == 200 83 | assert r.json() == {"message": "Password updated successfully"} 84 | 85 | user_query = select(User).where(User.email == settings.FIRST_SUPERUSER) 86 | user = db.exec(user_query).first() 87 | assert user 88 | assert verify_password(data["new_password"], user.hashed_password) 89 | 90 | 91 | def test_reset_password_invalid_token( 92 | client: TestClient, superuser_token_headers: dict[str, str] 93 | ) -> None: 94 | data = {"new_password": "changethis", "token": "invalid"} 95 | r = client.post( 96 | f"{settings.API_V1_STR}/reset-password/", 97 | headers=superuser_token_headers, 98 | json=data, 99 | ) 100 | response = r.json() 101 | 102 | assert "detail" in response 103 | assert r.status_code == 400 104 | assert response["detail"] == "Invalid token" 105 | -------------------------------------------------------------------------------- /backend/src/app/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator 2 | 3 | import pytest 4 | from backend.main import app 5 | from fastapi.testclient import TestClient 6 | from sqlmodel import Session, delete 7 | 8 | from app.core.config import settings 9 | from app.core.db import engine, init_db 10 | from app.models import Item, User 11 | from app.tests.utils.user import authentication_token_from_email 12 | from app.tests.utils.utils import get_superuser_token_headers 13 | 14 | 15 | @pytest.fixture(scope="session", autouse=True) 16 | def db() -> Generator[Session, None, None]: 17 | with Session(engine) as session: 18 | init_db(session) 19 | yield session 20 | statement = delete(Item) 21 | session.execute(statement) 22 | statement = delete(User) 23 | session.execute(statement) 24 | session.commit() 25 | 26 | 27 | @pytest.fixture(scope="module") 28 | def client() -> Generator[TestClient, None, None]: 29 | with TestClient(app) as c: 30 | yield c 31 | 32 | 33 | @pytest.fixture(scope="module") 34 | def superuser_token_headers(client: TestClient) -> dict[str, str]: 35 | return get_superuser_token_headers(client) 36 | 37 | 38 | @pytest.fixture(scope="module") 39 | def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]: 40 | return authentication_token_from_email( 41 | client=client, email=settings.EMAIL_TEST_USER, db=db 42 | ) 43 | -------------------------------------------------------------------------------- /backend/src/app/tests/crud/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/tests/crud/__init__.py -------------------------------------------------------------------------------- /backend/src/app/tests/crud/test_user.py: -------------------------------------------------------------------------------- 1 | from fastapi.encoders import jsonable_encoder 2 | from sqlmodel import Session 3 | 4 | from app import crud 5 | from app.core.security import verify_password 6 | from app.models import User, UserCreate, UserUpdate 7 | from app.tests.utils.utils import random_email, random_lower_string 8 | 9 | 10 | def test_create_user(db: Session) -> None: 11 | email = random_email() 12 | password = random_lower_string() 13 | user_in = UserCreate(email=email, password=password) 14 | user = crud.create_user(session=db, user_create=user_in) 15 | assert user.email == email 16 | assert hasattr(user, "hashed_password") 17 | 18 | 19 | def test_authenticate_user(db: Session) -> None: 20 | email = random_email() 21 | password = random_lower_string() 22 | user_in = UserCreate(email=email, password=password) 23 | user = crud.create_user(session=db, user_create=user_in) 24 | authenticated_user = crud.authenticate(session=db, email=email, password=password) 25 | assert authenticated_user 26 | assert user.email == authenticated_user.email 27 | 28 | 29 | def test_not_authenticate_user(db: Session) -> None: 30 | email = random_email() 31 | password = random_lower_string() 32 | user = crud.authenticate(session=db, email=email, password=password) 33 | assert user is None 34 | 35 | 36 | def test_check_if_user_is_active(db: Session) -> None: 37 | email = random_email() 38 | password = random_lower_string() 39 | user_in = UserCreate(email=email, password=password) 40 | user = crud.create_user(session=db, user_create=user_in) 41 | assert user.is_active is True 42 | 43 | 44 | def test_check_if_user_is_active_inactive(db: Session) -> None: 45 | email = random_email() 46 | password = random_lower_string() 47 | user_in = UserCreate(email=email, password=password, disabled=True) 48 | user = crud.create_user(session=db, user_create=user_in) 49 | assert user.is_active 50 | 51 | 52 | def test_check_if_user_is_superuser(db: Session) -> None: 53 | email = random_email() 54 | password = random_lower_string() 55 | user_in = UserCreate(email=email, password=password, is_superuser=True) 56 | user = crud.create_user(session=db, user_create=user_in) 57 | assert user.is_superuser is True 58 | 59 | 60 | def test_check_if_user_is_superuser_normal_user(db: Session) -> None: 61 | username = random_email() 62 | password = random_lower_string() 63 | user_in = UserCreate(email=username, password=password) 64 | user = crud.create_user(session=db, user_create=user_in) 65 | assert user.is_superuser is False 66 | 67 | 68 | def test_get_user(db: Session) -> None: 69 | password = random_lower_string() 70 | username = random_email() 71 | user_in = UserCreate(email=username, password=password, is_superuser=True) 72 | user = crud.create_user(session=db, user_create=user_in) 73 | user_2 = db.get(User, user.id) 74 | assert user_2 75 | assert user.email == user_2.email 76 | assert jsonable_encoder(user) == jsonable_encoder(user_2) 77 | 78 | 79 | def test_update_user(db: Session) -> None: 80 | password = random_lower_string() 81 | email = random_email() 82 | user_in = UserCreate(email=email, password=password, is_superuser=True) 83 | user = crud.create_user(session=db, user_create=user_in) 84 | new_password = random_lower_string() 85 | user_in_update = UserUpdate(password=new_password, is_superuser=True) 86 | if user.id is not None: 87 | crud.update_user(session=db, db_user=user, user_in=user_in_update) 88 | user_2 = db.get(User, user.id) 89 | assert user_2 90 | assert user.email == user_2.email 91 | assert verify_password(new_password, user_2.hashed_password) 92 | -------------------------------------------------------------------------------- /backend/src/app/tests/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/tests/scripts/__init__.py -------------------------------------------------------------------------------- /backend/src/app/tests/scripts/test_backend_pre_start.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from sqlmodel import select 4 | 5 | from app.backend_pre_start import init, logger 6 | 7 | 8 | def test_init_successful_connection() -> None: 9 | engine_mock = MagicMock() 10 | 11 | session_mock = MagicMock() 12 | exec_mock = MagicMock(return_value=True) 13 | session_mock.configure_mock(**{"exec.return_value": exec_mock}) 14 | 15 | with ( 16 | patch("sqlmodel.Session", return_value=session_mock), 17 | patch.object(logger, "info"), 18 | patch.object(logger, "error"), 19 | patch.object(logger, "warn"), 20 | ): 21 | try: 22 | init(engine_mock) 23 | connection_successful = True 24 | except Exception: 25 | connection_successful = False 26 | 27 | assert ( 28 | connection_successful 29 | ), "The database connection should be successful and not raise an exception." 30 | 31 | assert session_mock.exec.called_once_with( 32 | select(1) 33 | ), "The session should execute a select statement once." 34 | -------------------------------------------------------------------------------- /backend/src/app/tests/scripts/test_test_pre_start.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | from sqlmodel import select 4 | 5 | from app.tests_pre_start import init, logger 6 | 7 | 8 | def test_init_successful_connection() -> None: 9 | engine_mock = MagicMock() 10 | 11 | session_mock = MagicMock() 12 | exec_mock = MagicMock(return_value=True) 13 | session_mock.configure_mock(**{"exec.return_value": exec_mock}) 14 | 15 | with ( 16 | patch("sqlmodel.Session", return_value=session_mock), 17 | patch.object(logger, "info"), 18 | patch.object(logger, "error"), 19 | patch.object(logger, "warn"), 20 | ): 21 | try: 22 | init(engine_mock) 23 | connection_successful = True 24 | except Exception: 25 | connection_successful = False 26 | 27 | assert ( 28 | connection_successful 29 | ), "The database connection should be successful and not raise an exception." 30 | 31 | assert session_mock.exec.called_once_with( 32 | select(1) 33 | ), "The session should execute a select statement once." 34 | -------------------------------------------------------------------------------- /backend/src/app/tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/backend/src/app/tests/utils/__init__.py -------------------------------------------------------------------------------- /backend/src/app/tests/utils/item.py: -------------------------------------------------------------------------------- 1 | from sqlmodel import Session 2 | 3 | from app import crud 4 | from app.models import Item, ItemCreate 5 | from app.tests.utils.user import create_random_user 6 | from app.tests.utils.utils import random_lower_string 7 | 8 | 9 | def create_random_item(db: Session) -> Item: 10 | user = create_random_user(db) 11 | owner_id = user.id 12 | assert owner_id is not None 13 | title = random_lower_string() 14 | description = random_lower_string() 15 | item_in = ItemCreate(title=title, description=description) 16 | return crud.create_item(session=db, item_in=item_in, owner_id=owner_id) 17 | -------------------------------------------------------------------------------- /backend/src/app/tests/utils/user.py: -------------------------------------------------------------------------------- 1 | from fastapi.testclient import TestClient 2 | from sqlmodel import Session 3 | 4 | from app import crud 5 | from app.core.config import settings 6 | from app.models import User, UserCreate, UserUpdate 7 | from app.tests.utils.utils import random_email, random_lower_string 8 | 9 | 10 | def user_authentication_headers( 11 | *, client: TestClient, email: str, password: str 12 | ) -> dict[str, str]: 13 | data = {"username": email, "password": password} 14 | 15 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data) 16 | response = r.json() 17 | auth_token = response["access_token"] 18 | headers = {"Authorization": f"Bearer {auth_token}"} 19 | return headers 20 | 21 | 22 | def create_random_user(db: Session) -> User: 23 | email = random_email() 24 | password = random_lower_string() 25 | user_in = UserCreate(email=email, password=password) 26 | user = crud.create_user(session=db, user_create=user_in) 27 | return user 28 | 29 | 30 | def authentication_token_from_email( 31 | *, client: TestClient, email: str, db: Session 32 | ) -> dict[str, str]: 33 | """ 34 | Return a valid token for the user with given email. 35 | 36 | If the user doesn't exist it is created first. 37 | """ 38 | password = random_lower_string() 39 | user = crud.get_user_by_email(session=db, email=email) 40 | if not user: 41 | user_in_create = UserCreate(email=email, password=password) 42 | user = crud.create_user(session=db, user_create=user_in_create) 43 | else: 44 | user_in_update = UserUpdate(password=password) 45 | if not user.id: 46 | raise Exception("User id not set") 47 | user = crud.update_user(session=db, db_user=user, user_in=user_in_update) 48 | 49 | return user_authentication_headers(client=client, email=email, password=password) 50 | -------------------------------------------------------------------------------- /backend/src/app/tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | from fastapi.testclient import TestClient 5 | 6 | from app.core.config import settings 7 | 8 | 9 | def random_lower_string() -> str: 10 | return "".join(random.choices(string.ascii_lowercase, k=32)) 11 | 12 | 13 | def random_email() -> str: 14 | return f"{random_lower_string()}@{random_lower_string()}.com" 15 | 16 | 17 | def get_superuser_token_headers(client: TestClient) -> dict[str, str]: 18 | login_data = { 19 | "username": settings.FIRST_SUPERUSER, 20 | "password": settings.FIRST_SUPERUSER_PASSWORD, 21 | } 22 | r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) 23 | tokens = r.json() 24 | a_token = tokens["access_token"] 25 | headers = {"Authorization": f"Bearer {a_token}"} 26 | return headers 27 | -------------------------------------------------------------------------------- /backend/src/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.core.db import engine 4 | from sqlalchemy import Engine 5 | from sqlmodel import Session, select 6 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | max_tries = 60 * 5 # 5 minutes 12 | wait_seconds = 1 13 | 14 | 15 | @retry( 16 | stop=stop_after_attempt(max_tries), 17 | wait=wait_fixed(wait_seconds), 18 | before=before_log(logger, logging.INFO), 19 | after=after_log(logger, logging.WARN), 20 | ) 21 | def init(db_engine: Engine) -> None: 22 | try: 23 | with Session(db_engine) as session: 24 | # Try to create session to check if DB is awake 25 | session.exec(select(1)) 26 | except Exception as e: 27 | logger.error(e) 28 | raise e 29 | 30 | 31 | def main() -> None: 32 | logger.info("Initializing service") 33 | init(engine) 34 | logger.info("Service finished initializing") 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /backend/src/cloudbuild.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - name: 'gcr.io/cloud-builders/docker' 3 | args: ['build', '-f', 'Dockerfile', '-t', 'australia-southeast1-docker.pkg.dev/ai-engineer-template/fastapi-backend/fastapi-backend:latest','.'] 4 | - name: 'gcr.io/cloud-builders/docker' 5 | args: ['push', 'australia-southeast1-docker.pkg.dev/ai-engineer-template/fastapi-backend/fastapi-backend:latest'] 6 | -------------------------------------------------------------------------------- /backend/src/gcr-service-policy.yml: -------------------------------------------------------------------------------- 1 | bindings: 2 | - members: 3 | - allUsers 4 | role: roles/run.invoker 5 | -------------------------------------------------------------------------------- /backend/src/initial_data.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.core.db import engine, init_db 4 | from sqlmodel import Session 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def init() -> None: 11 | with Session(engine) as session: 12 | init_db(session) 13 | 14 | 15 | def main() -> None: 16 | logger.info("Creating initial data") 17 | init() 18 | logger.info("Initial data created") 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /backend/src/main.py: -------------------------------------------------------------------------------- 1 | # import sentry_sdk 2 | # from mangum import Mangum 3 | from app.api.main_route import api_router 4 | from app.core.config import settings 5 | from fastapi import FastAPI 6 | 7 | # from starlette.middleware.cors import CORSMiddleware 8 | from fastapi.middleware.cors import CORSMiddleware 9 | 10 | # def custom_generate_unique_id(route: APIRoute) -> str: 11 | # return f"{route.tags[0]}-{route.name}" 12 | 13 | 14 | # if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": 15 | # sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) 16 | 17 | app = FastAPI( 18 | title=settings.PROJECT_NAME, 19 | openapi_url=f"{settings.API_V1_STR}/openapi.json", 20 | # generate_unique_id_function=custom_generate_unique_id, 21 | ) 22 | 23 | 24 | @app.get("/") 25 | async def read_root(): 26 | return {"Hello": "Welcome to ai engineer template!"} 27 | 28 | 29 | # Set all CORS enabled origins 30 | # if settings.BACKEND_CORS_ORIGINS: 31 | # app.add_middleware( 32 | # CORSMiddleware, 33 | # # allow_origins=[ 34 | # # str(origin).strip("/") for origin in settings.BACKEND_CORS_ORIGINS 35 | # # ], 36 | # allow_origins=["*"], 37 | # allow_credentials=True, 38 | # allow_methods=["*"], 39 | # allow_headers=["*"], 40 | # ) 41 | 42 | origins = ["http://localhost:3000", "https://ai-engineer-template.vercel.app"] 43 | app.add_middleware( 44 | CORSMiddleware, 45 | allow_origins=origins, 46 | allow_credentials=True, 47 | allow_methods=["*"], 48 | allow_headers=["*"], 49 | ) 50 | 51 | app.include_router(api_router, prefix=settings.API_V1_STR) 52 | 53 | # handler = Mangum(app) 54 | 55 | 56 | # from typing import Union 57 | # from fastapi import FastAPI 58 | # from fastapi.middleware.cors import CORSMiddleware 59 | # from mangum import Mangum 60 | # import requests 61 | 62 | # app = FastAPI() 63 | 64 | # origins = ["http://localhost:3000", "*"] 65 | # app.add_middleware( 66 | # CORSMiddleware, 67 | # allow_origins=origins, 68 | # allow_credentials=True, 69 | # allow_methods=["*"], 70 | # allow_headers=["*"], 71 | # ) 72 | 73 | 74 | # @app.get("/") 75 | # async def read_root(): 76 | # return {"Hello": "World!"} 77 | 78 | 79 | # @app.get("/items/{item_id}") 80 | # async def read_item(item_id: int, q: Union[str, None] = None): 81 | # return {"item_id": item_id, "q": q} 82 | 83 | 84 | # @app.get("/call-external") 85 | # async def example_external_request(): 86 | # response = requests.get("https://jsonplaceholder.typicode.com/todos/1") 87 | # return response.json() 88 | 89 | 90 | # handler = Mangum(app) 91 | -------------------------------------------------------------------------------- /backend/src/service-example.yml: -------------------------------------------------------------------------------- 1 | apiVersion: serving.knative.dev/v1 2 | kind: Service 3 | metadata: 4 | name: ai-engineer-template-backend 5 | spec: 6 | template: 7 | spec: 8 | containers: 9 | - image: australia-southeast1-docker.pkg.dev/ai-engineer-template/fastapi-backend/fastapi-backend:latest 10 | ports: 11 | - containerPort: 8000 12 | env: 13 | - name: POSTGRES_SERVER 14 | value: "" 15 | - name: POSTGRES_PORT 16 | value: "" 17 | - name: POSTGRES_DB 18 | value: "" 19 | - name: POSTGRES_USER 20 | value: "" 21 | - name: POSTGRES_PASSWORD 22 | value: "" 23 | - name: ENCRYPTION_KEY 24 | value: "" 25 | - name: PROJECT_NAME 26 | value: "ai-engineer-template" 27 | - name: FIRST_SUPERUSER 28 | value: "" 29 | - name: FIRST_SUPERUSER_PASSWORD 30 | value: "" 31 | -------------------------------------------------------------------------------- /backend/src/tests_pre_start.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from app.core.db import engine 4 | from sqlalchemy import Engine 5 | from sqlmodel import Session, select 6 | from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed 7 | 8 | logging.basicConfig(level=logging.INFO) 9 | logger = logging.getLogger(__name__) 10 | 11 | max_tries = 60 * 5 # 5 minutes 12 | wait_seconds = 1 13 | 14 | 15 | @retry( 16 | stop=stop_after_attempt(max_tries), 17 | wait=wait_fixed(wait_seconds), 18 | before=before_log(logger, logging.INFO), 19 | after=after_log(logger, logging.WARN), 20 | ) 21 | def init(db_engine: Engine) -> None: 22 | try: 23 | # Try to create session to check if DB is awake 24 | with Session(db_engine) as session: 25 | session.exec(select(1)) 26 | except Exception as e: 27 | logger.error(e) 28 | raise e 29 | 30 | 31 | def main() -> None: 32 | logger.info("Initializing service") 33 | init(engine) 34 | logger.info("Service finished initializing") 35 | 36 | 37 | if __name__ == "__main__": 38 | main() 39 | -------------------------------------------------------------------------------- /development.md: -------------------------------------------------------------------------------- 1 | # How to develop in local 2 | 3 | The following guide will show you how to spin up this repo and develop locally. 4 | 5 | ## Nextjs frontend 6 | 7 | ```bash 8 | cd frontend 9 | npm install 10 | npm run dev 11 | ``` 12 | 13 | This will open a nextjs app at localhost:3000 14 | 15 | ## Supabase local containers 16 | 17 | Make sure you have already installed Supabase Cli, if not 18 | 19 | ```bash 20 | brew install supabase/tap/supabase 21 | ``` 22 | 23 | for windows, refer this link https://supabase.com/docs/guides/cli/getting-started?queryGroups=platform&platform=windows 24 | 25 | ``` 26 | supabase init 27 | ``` 28 | 29 | ``` 30 | supabase start 31 | ``` 32 | 33 | You can use the `supabase stop` command at any time to stop all services (without resetting your local database). Use `supabase stop --no-backup` to stop all services and reset your local database. 34 | 35 | ## DB Migration 36 | 37 | alembic will pick up all the new changes from sqlmodel. To push migration to the local Supabase docker container 38 | 39 | ```bash 40 | cd backend 41 | poetry shell 42 | ``` 43 | 44 | If this is the first time, run the prestart.sh to load the initial data 45 | 46 | ``` 47 | source prestart.sh 48 | ``` 49 | 50 | For ongoing alembic update, use 51 | 52 | ``` 53 | alembic revision --autogenerate -m "the scope for the migration" 54 | alembic upgrade head 55 | ``` 56 | 57 | ## FastAPI backend 58 | 59 | Make sure you have poetry installed before you start, if not 60 | 61 | ```bash 62 | brew install poetry 63 | ``` 64 | 65 | Start a new poetry environment (python 3.10) 66 | 67 | ```bash 68 | poetry use env 3.10 69 | ``` 70 | 71 | Activate the environment 72 | 73 | ```bash 74 | poetry shell 75 | ``` 76 | 77 | ```bash 78 | source prestart.sh 79 | ``` 80 | 81 | Start the unicorn server 82 | 83 | ```bash 84 | uvicorn main:app --reload 85 | ``` 86 | 87 | ## Type generate 88 | 89 | Grab the openapi.json file from the backend Swagger UI. Then replace in frontend/lib/api/openapi.json.
90 | Run `npm run types:generate`, it will generate a type file called v1.d.ts in the same folder.
91 | TO DO: automate this in a MAKE file. 92 | 93 | ## Linting check 94 | 95 | Linting check is done via pre-commit hook. In file '.pre-commit-config.yaml', it has all the configuration for the check. For the first time running in local, in the root directory you need to (use `poetry shell` to activate python env first) 96 | 97 | ```bash 98 | pre-commit install 99 | ``` 100 | 101 | Then the check will run while the code is being committed. Optionally, you can also run this hook for all files mannually. 102 | 103 | ```bash 104 | pre-commit run --all-files 105 | ``` 106 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | # Dockerfile 2 | # .dockerignore 3 | # node_modules 4 | # npm-debug.log 5 | # README.md 6 | # .next 7 | # .git 8 | node_modules 9 | .next 10 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | API_BASE_URL=http://localhost:8000 2 | NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 3 | # Google auth 4 | NEXT_PUBLIC_GOOGLE_CLIENT_ID= 5 | NEXT_PUBLIC_GOOGLE_REDIRECT_URI=http://localhost:3000/callback/google 6 | # Github auth 7 | NEXT_PUBLIC_GITHUB_CLIENT_ID= 8 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /frontend/.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const buildEslintCommand = (filenames) => 4 | `next lint --fix --file ${filenames 5 | .map((f) => path.relative(process.cwd(), f)) 6 | .join(' --file ')}` 7 | 8 | module.exports = { 9 | '*.{js,jsx,ts,tsx}': [buildEslintCommand], 10 | } 11 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 6 | RUN apk add --no-cache libc6-compat 7 | WORKDIR /app 8 | 9 | # Install dependencies based on the preferred package manager 10 | # COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 11 | # RUN \ 12 | # if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 13 | # elif [ -f package-lock.json ]; then npm ci; \ 14 | # elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 15 | # else echo "Lockfile not found." && exit 1; \ 16 | # fi 17 | 18 | COPY package.json package-lock.json ./ 19 | RUN npm ci 20 | 21 | # Rebuild the source code only when needed 22 | FROM base AS builder 23 | WORKDIR /app 24 | COPY --from=deps /app/node_modules ./node_modules 25 | COPY . . 26 | 27 | # Next.js collects completely anonymous telemetry data about general usage. 28 | # Learn more here: https://nextjs.org/telemetry 29 | # Uncomment the following line in case you want to disable telemetry during the build. 30 | ENV NEXT_TELEMETRY_DISABLED 1 31 | 32 | # RUN \ 33 | # if [ -f yarn.lock ]; then yarn run build; \ 34 | # elif [ -f package-lock.json ]; then npm run build; \ 35 | # elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 36 | # else echo "Lockfile not found." && exit 1; \ 37 | # fi 38 | 39 | ARG API_BASE_URL=${API_BASE_URL} 40 | 41 | ARG NEXT_PUBLIC_API_BASE_URL 42 | 43 | RUN npm run build 44 | 45 | # Production image, copy all the files and run next 46 | FROM base AS runner 47 | WORKDIR /app 48 | 49 | ENV NODE_ENV production 50 | # Uncomment the following line in case you want to disable telemetry during runtime. 51 | ENV NEXT_TELEMETRY_DISABLED 1 52 | 53 | RUN addgroup --system --gid 1001 nodejs 54 | RUN adduser --system --uid 1001 nextjs 55 | 56 | COPY --from=builder /app/public ./public 57 | 58 | # Set the correct permission for prerender cache 59 | RUN mkdir .next 60 | RUN chown nextjs:nodejs .next 61 | 62 | # Automatically leverage output traces to reduce image size 63 | # https://nextjs.org/docs/advanced-features/output-file-tracing 64 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 65 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 66 | 67 | USER nextjs 68 | 69 | # EXPOSE 3000 70 | 71 | # ENV PORT 3000 72 | 73 | # server.js is created by next build from the standalone output 74 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 75 | CMD HOSTNAME="0.0.0.0" node server.js 76 | # CMD ["node", "server.js"] 77 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /frontend/app/(protected)/admin/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import initiateClient from "@/lib/api"; 3 | import { revalidatePath } from "next/cache"; 4 | 5 | export async function addUsers(additionalInfo: string, formData: FormData) { 6 | const client = initiateClient(); 7 | const isSuperUser = JSON.parse(additionalInfo).isSuperUser; 8 | const isActive = JSON.parse(additionalInfo).isActive; 9 | console.log(additionalInfo, isSuperUser, isActive); 10 | const { data, error } = await client.POST("/api/v1/users/", { 11 | body: { 12 | email: formData.get("email") as string, 13 | is_active: isActive as boolean, 14 | is_superuser: isSuperUser as boolean, 15 | full_name: formData.get("fullName") as string, 16 | password: formData.get("password") as string, 17 | }, 18 | }); 19 | if (error) { 20 | console.error(error); 21 | return { error: error.detail }; 22 | } 23 | revalidatePath("/admin"); 24 | } 25 | 26 | export async function editUser(additionalInfo: string, formData: FormData) { 27 | const client = initiateClient(); 28 | const userId = JSON.parse(additionalInfo).userId; 29 | const isSuperUser = JSON.parse(additionalInfo).isSuperUser; 30 | const isActive = JSON.parse(additionalInfo).isActive; 31 | const { data, error } = await client.PATCH("/api/v1/users/{user_id}", { 32 | body: { 33 | email: formData.get("email") as string, 34 | is_active: isActive, 35 | is_superuser: isSuperUser, 36 | full_name: formData.get("fullName") as string, 37 | password: formData.get("password") as string, 38 | }, 39 | params: { path: { user_id: Number(userId) } }, 40 | }); 41 | if (error) { 42 | console.error(error); 43 | return { error: error.detail }; 44 | } 45 | revalidatePath("/admin"); 46 | } 47 | 48 | export async function deleteUser(userId: string) { 49 | const client = initiateClient(); 50 | const { data, error } = await client.DELETE("/api/v1/users/{user_id}", { 51 | params: { path: { user_id: Number(userId) } }, 52 | }); 53 | if (error) { 54 | console.log(error); 55 | return { error: error.detail }; 56 | } 57 | revalidatePath("/admin"); 58 | } 59 | -------------------------------------------------------------------------------- /frontend/app/(protected)/admin/page.tsx: -------------------------------------------------------------------------------- 1 | import { AddUser } from "@/components/add-user"; 2 | import UsersTable from "@/components/users-table"; 3 | import React from "react"; 4 | 5 | function AdminPage() { 6 | return ( 7 |
8 |

User Management

9 |
10 | 11 |
12 | 13 |
14 |
15 |
16 | ); 17 | } 18 | 19 | export default AdminPage; 20 | -------------------------------------------------------------------------------- /frontend/app/(protected)/chat/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import ChatSettings from "@/components/chat-settings"; 2 | import ChatUI from "@/components/chat-ui"; 3 | import initiateClient from "@/lib/api"; 4 | 5 | export default async function ChatPage() { 6 | const client = initiateClient(); 7 | async function getChatConfig() { 8 | const { data, error } = await client.GET("/api/v1/chat/config", { 9 | cache: "no-store", 10 | }); 11 | if (error) { 12 | console.log(error); 13 | // return []; 14 | } 15 | return data; 16 | } 17 | const chatConfig = await getChatConfig(); 18 | return ( 19 |
20 |
21 |

Chat

22 | 23 |
24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/app/(protected)/chat/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import initiateClient from "@/lib/api"; 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export async function addChatConfig(formData: FormData) { 7 | const client = initiateClient(); 8 | const { data, error } = await client.POST("/api/v1/chat/config", { 9 | body: { 10 | model: formData.get("model") as string, 11 | api_key: formData.get("api-key") as string, 12 | temperature: Number(formData.get("temperature")), 13 | top_p: Number(formData.get("top-p")), 14 | // topK: Number(formData.get("topK")), 15 | system_message: formData.get("system-message") as string, 16 | }, 17 | }); 18 | if (error) { 19 | console.log(error); 20 | redirect(`/chat?message=${error.detail}`); 21 | } 22 | revalidatePath("/chat"); 23 | redirect("/chat"); 24 | } 25 | 26 | export async function editChatConfig(formData: FormData) { 27 | const client = initiateClient(); 28 | const { data, error } = await client.PUT("/api/v1/chat/config", { 29 | body: { 30 | model: formData.get("model") as string, 31 | api_key: formData.get("api-key") as string, 32 | temperature: Number(formData.get("temperature")), 33 | top_p: Number(formData.get("top-p")), 34 | // topK: Number(formData.get("topK")), 35 | system_message: formData.get("system-message") as string, 36 | }, 37 | }); 38 | if (error) { 39 | console.log(error); 40 | redirect(`/chat?message=${error.detail}`); 41 | } 42 | revalidatePath("/chat"); 43 | redirect("/chat"); 44 | } 45 | -------------------------------------------------------------------------------- /frontend/app/(protected)/chat/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import ChatSettings from "@/components/chat-settings"; 3 | import ChatUI from "@/components/chat-ui"; 4 | import initiateClient from "@/lib/api"; 5 | import { nanoid } from "nanoid"; 6 | import { useRouter } from "next/navigation"; 7 | 8 | export default async function ChatPage() { 9 | const router = useRouter(); 10 | router.push(`/chat/${nanoid()}`); 11 | return ( 12 | // todo: implement a loading spinner as it transits to the individual chat page 13 |
14 | {/*
15 |

Chat

16 | 17 |
18 | */} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/app/(protected)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { useUser } from "@/lib/context/UserContext"; 5 | 6 | function Page() { 7 | const user = useUser(); 8 | return ( 9 |
10 |

Hi, {user?.email} 👋🏼

11 |

Welcome back, nice to see you again!

12 |
13 | ); 14 | } 15 | 16 | export default Page; 17 | -------------------------------------------------------------------------------- /frontend/app/(protected)/items/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import initiateClient from "@/lib/api"; 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export async function addItems(formData: FormData) { 7 | const client = initiateClient(); 8 | const { data, error } = await client.POST("/api/v1/items/", { 9 | body: { 10 | title: formData.get("title") as string, 11 | description: formData.get("description") as string, 12 | }, 13 | }); 14 | if (error) { 15 | console.log(error); 16 | redirect(`/items?message=${error.detail}`); 17 | } 18 | revalidatePath("/items"); 19 | redirect("/items"); 20 | } 21 | 22 | export async function deleteItem(itemId: string) { 23 | const client = initiateClient(); 24 | const { data, error } = await client.DELETE("/api/v1/items/{id}", { 25 | params: { path: { id: Number(itemId) } }, 26 | }); 27 | if (error) { 28 | console.log(error); 29 | redirect(`/items?message=${error.detail}`); 30 | } 31 | revalidatePath("/items"); 32 | redirect("/items"); 33 | } 34 | 35 | export async function editItem(itemId: string, formData: FormData) { 36 | const client = initiateClient(); 37 | const { data, error } = await client.PUT("/api/v1/items/{id}", { 38 | body: { 39 | title: formData.get("title") as string, 40 | description: formData.get("description") as string, 41 | }, 42 | params: { path: { id: Number(itemId) } }, 43 | }); 44 | if (error) { 45 | console.log(error); 46 | redirect(`/items?message=${error.detail}`); 47 | } 48 | revalidatePath("/items"); 49 | redirect("/items"); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/app/(protected)/items/page.tsx: -------------------------------------------------------------------------------- 1 | import { AddItem } from "@/components/add-item"; 2 | import ItemsTable from "@/components/items-table"; 3 | import React from "react"; 4 | 5 | function ItemsPage() { 6 | return ( 7 |
8 |

Items Management

9 |
10 | 11 |
12 | 13 |
14 |
15 |
16 | ); 17 | } 18 | 19 | export default ItemsPage; 20 | -------------------------------------------------------------------------------- /frontend/app/(protected)/layout.tsx: -------------------------------------------------------------------------------- 1 | import NavLink from "@/components/nav-link"; 2 | import DashboardHeader from "@/components/dashboard-header"; 3 | import initiateClient from "@/lib/api"; 4 | import { redirect } from "next/navigation"; 5 | import { cookies } from "next/headers"; 6 | import { unstable_noStore as noStore, revalidatePath } from "next/cache"; 7 | import { UserProvider } from "@/lib/context/UserContext"; 8 | import { User } from "lucide-react"; 9 | 10 | export default async function Layout({ 11 | children, 12 | }: { 13 | children: React.ReactNode; 14 | }) { 15 | // noStore(); 16 | 17 | // if (!cookies().get("access_token")) { 18 | // console.log("No access token found"); 19 | // redirect('/') 20 | // } 21 | const client = initiateClient(); 22 | const { data, error } = await client.GET("/api/v1/users/me", { 23 | credentials: "include", 24 | headers: { 25 | Authorization: `Bearer ${cookies().get("access_token")?.value}`, 26 | }, 27 | cache: "no-store", 28 | }); 29 | if (error) { 30 | //TODO: handle error 31 | console.log(error); 32 | revalidatePath("/"); 33 | redirect("/login"); 34 | } 35 | 36 | return ( 37 |
38 | 39 |
40 | 41 | 42 |
{children}
43 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /frontend/app/(protected)/settings/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import initiateClient from "@/lib/api"; 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export async function updateProfile(formData: FormData) { 7 | const client = initiateClient(); 8 | const { error } = await client.PATCH("/api/v1/users/me", { 9 | body: { 10 | full_name: formData.get("fullName") as string, 11 | email: formData.get("email") as string, 12 | }, 13 | }); 14 | if (error) { 15 | console.error(error); 16 | redirect(`/settings/?message=${error.detail}`); 17 | } 18 | revalidatePath("/settings"); 19 | } 20 | 21 | export async function updatePassword(formData: FormData) { 22 | const client = initiateClient(); 23 | const { data, error } = await client.PATCH("/api/v1/users/me/password", { 24 | body: { 25 | current_password: formData.get("currentPassword") as string, 26 | new_password: formData.get("newPassword") as string, 27 | }, 28 | }); 29 | if (error) { 30 | console.error(error); 31 | return {error: error.detail} 32 | } 33 | revalidatePath("/settings"); 34 | } 35 | -------------------------------------------------------------------------------- /frontend/app/(protected)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import Appearance from "@/components/appearance-settings"; 2 | import MyProfile from "@/components/my-profile"; 3 | import PasswordReset from "@/components/password-reset"; 4 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 5 | import initiateClient from "@/lib/api"; 6 | 7 | async function SettingsPage() { 8 | const client = initiateClient(); 9 | const { data, error } = await client.GET("/api/v1/users/me"); 10 | if (error) { 11 | //TODO: handle error 12 | console.log(error); 13 | } 14 | const fullName = data?.full_name; 15 | const email = data?.email; 16 | return ( 17 |
18 |

User Settings

19 |
20 | 21 |
22 | 23 | My Profile 24 | Password 25 | Appearance 26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 |
39 |
40 | ); 41 | } 42 | 43 | export default SettingsPage; 44 | -------------------------------------------------------------------------------- /frontend/app/(protected)/shop/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import ChatSettings from "@/components/chat-settings"; 2 | import ChatUI from "@/components/chat-ui"; 3 | import initiateClient from "@/lib/api"; 4 | 5 | export default async function ShopPage() { 6 | const client = initiateClient(); 7 | async function getChatConfig() { 8 | const { data, error } = await client.GET("/api/v1/chat/config", { 9 | cache: "no-store", 10 | }); 11 | if (error) { 12 | console.log(error); 13 | // return []; 14 | } 15 | return data; 16 | } 17 | const chatConfig = await getChatConfig(); 18 | return ( 19 |
20 |
21 |

Shopping Companion

22 | 23 |
24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /frontend/app/(protected)/shop/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import initiateClient from "@/lib/api"; 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export async function addChatConfig(formData: FormData) { 7 | const client = initiateClient(); 8 | const { data, error } = await client.POST("/api/v1/chat/config", { 9 | body: { 10 | model: formData.get("model") as string, 11 | api_key: formData.get("api-key") as string, 12 | temperature: Number(formData.get("temperature")), 13 | top_p: Number(formData.get("top-p")), 14 | // topK: Number(formData.get("topK")), 15 | system_message: formData.get("system-message") as string, 16 | }, 17 | }); 18 | if (error) { 19 | console.log(error); 20 | redirect(`/chat?message=${error.detail}`); 21 | } 22 | revalidatePath("/chat"); 23 | redirect("/chat"); 24 | } 25 | 26 | export async function editChatConfig(formData: FormData) { 27 | const client = initiateClient(); 28 | const { data, error } = await client.PUT("/api/v1/chat/config", { 29 | body: { 30 | model: formData.get("model") as string, 31 | api_key: formData.get("api-key") as string, 32 | temperature: Number(formData.get("temperature")), 33 | top_p: Number(formData.get("top-p")), 34 | // topK: Number(formData.get("topK")), 35 | system_message: formData.get("system-message") as string, 36 | }, 37 | }); 38 | if (error) { 39 | console.log(error); 40 | redirect(`/chat?message=${error.detail}`); 41 | } 42 | revalidatePath("/chat"); 43 | redirect("/chat"); 44 | } 45 | -------------------------------------------------------------------------------- /frontend/app/(protected)/shop/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | // import ChatSettings from "@/components/chat-settings"; 3 | // import ChatUI from "@/components/chat-ui"; 4 | // import initiateClient from "@/lib/api"; 5 | import { nanoid } from "nanoid"; 6 | import { useRouter } from "next/navigation"; 7 | 8 | export default async function ShopPage() { 9 | const router = useRouter(); 10 | router.push(`/shop/${nanoid()}`); 11 | return ( 12 | // todo: implement a loading spinner as it transits to the individual chat page 13 |
14 | {/*
15 |

Chat

16 | 17 |
18 | */} 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /frontend/app/callback/github/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import initiateClient from "@/lib/api"; 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | import { cookies } from "next/headers"; 6 | 7 | export async function getAccessTokenGithubAuth(code: string) { 8 | const client = initiateClient(); 9 | const { data, error } = await client.POST("/api/v1/auth/github", { 10 | body: { 11 | code: code, 12 | } as { code: string }, 13 | cache: "no-store", 14 | }); 15 | if (error) { 16 | console.log(error); 17 | // redirect("/login"); 18 | } 19 | if (data) { 20 | cookies().set("access_token", data.access_token); 21 | // revalidatePath("/", "layout"); 22 | redirect("/dashboard"); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/app/callback/github/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // callback function to handle OAuth2 authorization code flow, authenticators will redirect to this page 4 | // after the user has authenticated on their platoform (e.g., Google, GitHub, etc.) 5 | 6 | import { useEffect, useState } from "react"; 7 | import { getAccessTokenGithubAuth } from "./actions"; 8 | 9 | const BACKEND_URL = process.env.NEXT_PUBLIC_API_BASE_URL; 10 | 11 | export default function Callback() { 12 | const [hasFetched, setHasFetched] = useState(false); 13 | useEffect(() => { 14 | if (!hasFetched) { 15 | const queryParams = new URLSearchParams(window.location.search); 16 | const code = queryParams.get("code"); 17 | if (code) { 18 | const getAccessTokenGithubAuthwithCode = getAccessTokenGithubAuth.bind( 19 | null, 20 | code as string 21 | ); 22 | getAccessTokenGithubAuthwithCode(); 23 | setHasFetched(true); 24 | } else { 25 | console.error("Authorization code is missing"); 26 | } 27 | } 28 | }, [hasFetched]); 29 | 30 | return
Processing login...
; 31 | } 32 | -------------------------------------------------------------------------------- /frontend/app/callback/google/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import initiateClient from "@/lib/api"; 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | import { cookies } from "next/headers"; 6 | 7 | export async function getAccessTokenGoogleAuth(code: string) { 8 | const client = initiateClient(); 9 | const { data, error } = await client.POST("/api/v1/auth/google", { 10 | body: { 11 | code: code, 12 | } as { code: string }, 13 | cache: "no-store", 14 | }); 15 | if (error) { 16 | console.log(error); 17 | // redirect("/login"); 18 | } 19 | if (data) { 20 | cookies().set("access_token", data.access_token); 21 | // revalidatePath("/", "layout"); 22 | redirect("/dashboard"); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/app/callback/google/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // callback function to handle OAuth2 authorization code flow, authenticators will redirect to this page 4 | // after the user has authenticated on their platoform (e.g., Google, GitHub, etc.) 5 | 6 | import { useEffect, useState } from "react"; 7 | import { getAccessTokenGoogleAuth } from "./actions"; 8 | 9 | const BACKEND_URL = process.env.NEXT_PUBLIC_API_BASE_URL; 10 | 11 | export default function Callback() { 12 | const [hasFetched, setHasFetched] = useState(false); 13 | useEffect(() => { 14 | if (!hasFetched) { 15 | const queryParams = new URLSearchParams(window.location.search); 16 | const code = queryParams.get("code"); 17 | if (code) { 18 | const getAccessTokenGoogleAuthwithCode = getAccessTokenGoogleAuth.bind( 19 | null, 20 | code as string 21 | ); 22 | getAccessTokenGoogleAuthwithCode(); 23 | setHasFetched(true); 24 | } else { 25 | console.error("Authorization code is missing"); 26 | } 27 | } 28 | }, [hasFetched]); 29 | 30 | return
Processing login...
; 31 | } 32 | -------------------------------------------------------------------------------- /frontend/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/frontend/app/favicon.ico -------------------------------------------------------------------------------- /frontend/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: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /frontend/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import { Toaster } from "@/components/ui/toaster" 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | 6 | import "./globals.css"; 7 | 8 | const inter = Inter({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Nextjs FastAPI template", 12 | description: 13 | "Starter template for building nextjs frontend and fastapi backend", 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: Readonly<{ 19 | children: React.ReactNode; 20 | }>) { 21 | return ( 22 | 23 | 24 | 30 | {children} 31 | 32 | 33 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /frontend/app/login/actions.tsx: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | import { cookies } from "next/headers"; 6 | import initiateClient from "@/lib/api"; 7 | 8 | export async function login(formData: FormData) { 9 | const client = initiateClient(); 10 | const { data, error } = await client.POST("/api/v1/login/access-token", { 11 | body: { 12 | grant_type: "", 13 | username: formData.get("username") as string, 14 | password: formData.get("password") as string, 15 | scope: "", 16 | client_id: "", 17 | client_secret: "", 18 | }, 19 | bodySerializer(body: any) { 20 | const fd = new FormData(); 21 | for (const name in body) { 22 | fd.append(name, body[name]); 23 | } 24 | return fd; 25 | }, 26 | cache: "no-store", 27 | }); 28 | if (error) { 29 | console.log(error); 30 | redirect(`/login?message=${error.detail}`); 31 | } 32 | cookies().set("access_token", data.access_token); 33 | revalidatePath("/", "layout"); 34 | redirect("/dashboard"); 35 | } 36 | 37 | export async function signup(formData: FormData) { 38 | const client = initiateClient(); 39 | const { data, error } = await client.POST("/api/v1/users/signup", { 40 | body: { 41 | email: formData.get("username") as string, 42 | password: formData.get("password") as string, 43 | }, 44 | cache: "no-store", 45 | }); 46 | if (error) { 47 | console.log(error); 48 | redirect(`/login?message=${error.detail}`); 49 | } 50 | revalidatePath("/", "layout"); 51 | redirect("/login?message=Check your email to continue sign in process"); 52 | } 53 | 54 | export async function logout() { 55 | console.log("logging out..."); 56 | cookies().delete("access_token"); 57 | revalidatePath("/", "layout"); 58 | redirect("/"); 59 | } 60 | -------------------------------------------------------------------------------- /frontend/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { SubmitButton } from "@/components/ui/submit-button"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Label } from "@/components/ui/label"; 13 | import { login, signup } from "./actions"; 14 | import { Github, Mail } from "lucide-react"; 15 | import { OAuthLogin } from "@/components/oauth-login"; 16 | 17 | export default function LoginPage({ 18 | searchParams, 19 | }: { 20 | searchParams: { message?: string }; 21 | }) { 22 | return ( 23 |
24 | 25 | 26 | Welcome Back! 27 | 28 | Enter your email below to login to your account 29 | 30 | 31 | 32 |
33 |
34 | {/*
*/} 35 |
36 | 37 | 43 | {/*
*/} 44 | {/*
*/} 45 |
46 | 47 | 51 | Forgot your password? 52 | 53 |
54 | 55 |
56 | {/*
*/} 57 |
58 | 64 | Login 65 | 66 | 72 | Sign up 73 | 74 |
75 |
76 |
77 |
78 | 79 |
80 |
81 | 82 | Or continue with 83 | 84 |
85 |
86 |
87 | {searchParams?.message && ( 88 |

89 | {searchParams.message} 90 |

91 | )} 92 | 93 |
94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /frontend/app/login/password-recovery/actions.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sheldenburg/ai-engineer-template/9985085b8c43ef9526db30a1ad37010680d546b8/frontend/app/login/password-recovery/actions.tsx -------------------------------------------------------------------------------- /frontend/app/login/password-recovery/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | import { SubmitButton } from "@/components/ui/submit-button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Label } from "@/components/ui/label"; 13 | 14 | export default function PasswordRecoveryPage({ 15 | searchParams, 16 | }: { 17 | searchParams: { message?: string }; 18 | }) { 19 | return ( 20 |
21 | 22 | 23 | Password Recovery 24 | 25 | A password recovery email will be sent to the registered account 26 | 27 | 28 | 29 |
30 |
31 | 32 | 38 |
39 | 46 | Continue 47 | 48 |
49 | {searchParams?.message && ( 50 |

51 | {searchParams.message} 52 |

53 | )} 54 |
55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /frontend/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/components/add-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogFooter, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Label } from "@/components/ui/label"; 13 | import { Plus } from "lucide-react"; 14 | import { addItems } from "@/app/(protected)/items/actions"; 15 | import { useState } from "react"; 16 | import { useToast } from "./ui/use-toast"; 17 | 18 | export function AddItem() { 19 | const [open, setOpen] = useState(false); 20 | const { toast } = useToast(); 21 | return ( 22 |
23 | 24 | 25 | 32 | 33 | 34 |
35 | 36 | Add Item 37 | 38 |
39 |
40 | 43 | 50 |
51 |
52 | 55 | 61 |
62 |
63 | 64 | 77 | 86 | 87 |
88 |
89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /frontend/components/appearance-settings.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; 3 | import { Label } from "./ui/label"; 4 | import { useTheme } from "next-themes"; 5 | 6 | function Appearance() { 7 | const { theme, setTheme } = useTheme(); 8 | return ( 9 |
10 |

Appearance

11 | 12 |
13 | setTheme('light')} checked={theme==='light'}/> 14 | 15 |

DEFAULT

16 |
17 |
18 | setTheme('dark')} checked={theme==='dark'}/> 19 | 20 |
21 |
22 |
23 | ); 24 | } 25 | 26 | export default Appearance; 27 | -------------------------------------------------------------------------------- /frontend/components/chat-history.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import Link from "next/link"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | // import { SidebarList } from '@/components/sidebar-list' 7 | import { buttonVariants } from "@/components/ui/button"; 8 | // import { IconPlus } from '@/components/ui/icons' 9 | import { Plus } from "lucide-react"; 10 | 11 | function ChatHistory({ chatList }: { chatList?: any }) { 12 | return ( 13 |
14 |
15 |

Chat History

16 |
17 |
18 | 25 | 26 | New Chat 27 | 28 |
29 | 32 | {Array.from({ length: 10 }).map((_, i) => ( 33 |
37 | ))} 38 |
39 | } 40 | > 41 | {/* @ts-ignore */} 42 | {/* */} 43 |
44 | {chatList.map((chat: any) => ( 45 | 50 | {chat.title.length > 30 51 | ? `${chat.title.substring(0, 30)}...` 52 | : chat.title} 53 | 54 | ))} 55 |
56 |
57 |
58 | ); 59 | } 60 | 61 | export default ChatHistory; 62 | -------------------------------------------------------------------------------- /frontend/components/dark-mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Moon, Sun } from "lucide-react" 5 | import { useTheme } from "next-themes" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu" 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme() 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /frontend/components/dashboard-header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { CircleUser, User } from "lucide-react"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuLabel, 9 | DropdownMenuSeparator, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import MobileNavLink from "./mobile-nav-link"; 13 | import Link from "next/link"; 14 | import { LogOut } from "lucide-react"; 15 | import { revalidatePath } from "next/cache"; 16 | import { redirect } from "next/navigation"; 17 | import { logout } from "@/app/login/actions"; 18 | import { useState } from "react"; 19 | 20 | function DashboardHeader({ user }: { user: { email: string } }) { 21 | const [open, setOpen] = useState(false); 22 | return ( 23 |
24 | 25 |
26 | {/*
27 |
28 | 29 | 34 |
35 |
*/} 36 |
37 |
38 | 39 | 40 | 44 | 45 | 46 | {/* My Account */} 47 | {/* */} 48 | setOpen(!open)}> 49 | 50 | 51 | My Profile 52 | 53 | 54 | {/* Support */} 55 | 56 | 57 |
58 | 66 |
67 |
68 |
69 |
70 |
71 |
72 | ); 73 | } 74 | 75 | export default DashboardHeader; 76 | -------------------------------------------------------------------------------- /frontend/components/delete-item-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogTrigger, 11 | } from "@/components/ui/dialog"; 12 | import { useState } from "react"; 13 | import { Trash2 } from "lucide-react"; 14 | import { deleteItem } from "@/app/(protected)/items/actions"; 15 | import { useToast } from "./ui/use-toast"; 16 | 17 | export function DeleteItemDialog({ itemId, popOverSetOpen }: { itemId: number, popOverSetOpen: (value: boolean) => void}) { 18 | const [open, setOpen] = useState(false); 19 | const { toast } = useToast(); 20 | const deleteItemwithId = deleteItem.bind(null, itemId.toString()); // bind only works with string not number 21 | return ( 22 | 23 | 24 | 32 | 33 | 34 |
35 | 36 | Delete Item 37 | 38 | Are you sure? You will not be able to undo this action. 39 | 40 | 41 | 42 | 57 | 67 | 68 |
69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /frontend/components/delete-user-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogTrigger, 11 | } from "@/components/ui/dialog"; 12 | import { useState } from "react"; 13 | import { Trash2 } from "lucide-react"; 14 | import { deleteUser } from "@/app/(protected)/admin/actions"; 15 | import { useToast } from "./ui/use-toast"; 16 | 17 | export function DeleteUserDialog({ 18 | userId, 19 | popOverSetOpen, 20 | }: { 21 | userId: number; 22 | popOverSetOpen: (value: boolean) => void; 23 | }) { 24 | const [open, setOpen] = useState(false); 25 | const { toast } = useToast(); 26 | const deleteUserwithId = deleteUser.bind(null, userId.toString()); // bind only works with string not number 27 | async function handleSubmit() { 28 | const result = await deleteUserwithId(); 29 | if (result?.error) { 30 | setOpen(false); 31 | popOverSetOpen(false); 32 | return toast({ 33 | title: "Something went wrong!", 34 | description: String(result.error), 35 | variant: "destructive", 36 | }); 37 | } else { 38 | setOpen(false); 39 | popOverSetOpen(false); 40 | return toast({ 41 | title: "Success", 42 | description: "User was deleted successfully!", 43 | }); 44 | } 45 | } 46 | return ( 47 | 48 | 49 | 57 | 58 | 59 |
60 | 61 | Delete User 62 | 63 | All items associated with this user will also be permantly 64 | deleted. Are you sure? You will not be able to undo this action. 65 | 66 | 67 | 68 | 75 | 85 | 86 |
87 |
88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /frontend/components/edit-item-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Label } from "./ui/label"; 4 | import { Input } from "./ui/input"; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from "@/components/ui/dialog"; 13 | import { useState } from "react"; 14 | import { SquarePen } from "lucide-react"; 15 | import { editItem } from "@/app/(protected)/items/actions"; 16 | import { useToast } from "./ui/use-toast"; 17 | 18 | export function EditItemDialog({ 19 | itemId, 20 | popOverSetOpen, 21 | }: { 22 | itemId: number; 23 | popOverSetOpen: (value: boolean) => void; 24 | }) { 25 | const [open, setOpen] = useState(false); 26 | const { toast } = useToast(); 27 | const editItemwithId = editItem.bind(null, itemId.toString()); // bind only works with string not number 28 | return ( 29 | 30 | 31 | 35 | 36 | 37 |
38 | 39 | Edit Item 40 | 41 |
42 |
43 | 46 | 53 |
54 |
55 | 58 | 64 |
65 |
66 | 67 | 81 | 91 | 92 |
93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /frontend/components/github-star.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useState } from "react"; 4 | 5 | // GitHubStars component 6 | const GitHubStars = () => { 7 | const [stars, setStars] = useState(null); 8 | 9 | useEffect(() => { 10 | fetch("https://api.github.com/repos/Sheldenburg/ai-engineer-template") 11 | .then((response) => response.json()) 12 | .then((data) => setStars(data.stargazers_count)) 13 | .catch((error) => console.error("Error fetching star count:", error)); 14 | }, []); 15 | 16 | return ( 17 | 23 | {stars !== null ? ( 24 | 30 | ) : ( 31 | "Loading..." 32 | )} 33 | 34 | ); 35 | }; 36 | 37 | export default GitHubStars; 38 | -------------------------------------------------------------------------------- /frontend/components/items-table.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Table, 4 | TableBody, 5 | TableHead, 6 | TableHeader, 7 | TableRow, 8 | } from "@/components/ui/table"; 9 | import initiateClient from "@/lib/api"; 10 | import TableRowItem from "@/components/table-row-item"; 11 | 12 | async function ItemsTable() { 13 | const client = initiateClient(); 14 | async function getItems() { 15 | const { data, error } = await client.GET("/api/v1/items/", { 16 | cache: "no-store", 17 | }); 18 | if (error) { 19 | console.log(error); 20 | // return []; 21 | } 22 | return data; 23 | } 24 | const items = await getItems(); 25 | return ( 26 | 27 | 28 | 29 | Id 30 | Title 31 | Description 32 | Actions 33 | 34 | 35 | 36 | {items && 37 | ( 38 | items as { 39 | data: { 40 | title: string; 41 | description?: string | null | undefined; 42 | id: number; 43 | owner_id: number; 44 | }[]; 45 | count: number; 46 | } 47 | ).data.map((item) => )} 48 | 49 |
50 | ); 51 | } 52 | 53 | export default ItemsTable; 54 | -------------------------------------------------------------------------------- /frontend/components/main-nav-items.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | import { 4 | Home, 5 | Settings, 6 | BotMessageSquare, 7 | Briefcase, 8 | Users, 9 | ShoppingBag, 10 | } from "lucide-react"; 11 | import { usePathname } from "next/navigation"; 12 | 13 | function MainNavItems() { 14 | const pathname = usePathname(); 15 | 16 | return ( 17 | 83 | ); 84 | } 85 | 86 | export default MainNavItems; 87 | -------------------------------------------------------------------------------- /frontend/components/mian-nav-items-mob.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | import Image from "next/image"; 4 | import { Home, Settings, LogOut, Briefcase, Users, Menu } from "lucide-react"; 5 | import { Button } from "@/components/ui/button"; 6 | import { usePathname } from "next/navigation"; 7 | import { logout } from "@/app/login/actions"; 8 | 9 | function MainNavItemsMob() { 10 | const pathname = usePathname(); 11 | return ( 12 | 82 | ); 83 | } 84 | 85 | export default MainNavItemsMob; 86 | -------------------------------------------------------------------------------- /frontend/components/mobile-nav-link.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Link from "next/link"; 3 | import Image from "next/image"; 4 | import { Home, Settings, LogOut, Briefcase, Users, Menu } from "lucide-react"; 5 | import { Button } from "@/components/ui/button"; 6 | import { redirect, usePathname } from "next/navigation"; 7 | import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; 8 | import { revalidatePath } from "next/cache"; 9 | import { logout } from "@/app/login/actions"; 10 | import { useState } from "react"; 11 | import MainNavItemsMob from "./mian-nav-items-mob"; 12 | import { Switch } from "@/components/ui/switch"; 13 | import ChatHistory from "./chat-history"; 14 | // import { 15 | // Card, 16 | // CardContent, 17 | // CardDescription, 18 | // CardHeader, 19 | // CardTitle, 20 | // } from "@/components/ui/card"; 21 | 22 | function MobileNavLink({ user }: { user: { email: string; chatList?: any } }) { 23 | const pathname = usePathname(); 24 | const [showSecondNav, setShowSecondNav] = useState(false); 25 | const handleToggleSwitch = () => { 26 | setShowSecondNav((prev: boolean) => !prev); 27 | console.log(showSecondNav); 28 | console.log(pathname); 29 | }; 30 | return ( 31 | 32 | 33 | 37 | 38 | 39 | {!showSecondNav ? ( 40 | 41 | ) : ( 42 | 43 | )} 44 |
45 |
46 | 47 |

Show Chat History

48 |
49 |

logged in as:

50 |

{user.email}

51 | {/* 52 | 53 | Upgrade to Pro 54 | 55 | Unlock all features and get unlimited access to our support 56 | team. 57 | 58 | 59 | 60 | 63 | 64 | */} 65 |
66 |
67 |
68 | ); 69 | } 70 | 71 | export default MobileNavLink; 72 | -------------------------------------------------------------------------------- /frontend/components/my-profile.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "./ui/button"; 3 | import { useState } from "react"; 4 | import { Label } from "./ui/label"; 5 | import { Input } from "./ui/input"; 6 | import { updateProfile } from "@/app/(protected)/settings/actions"; 7 | import { useToast } from "./ui/use-toast"; 8 | 9 | function MyProfile({ 10 | fullName, 11 | email, 12 | }: { 13 | fullName: string | null | undefined; 14 | email: string; 15 | }) { 16 | const [edit, setEdit] = useState(false); 17 | const [fullNameClient, setFullNameClient] = useState(""); 18 | const [emailClient, setEmailClient] = useState(""); 19 | const { toast } = useToast(); 20 | const handleCanel = () => { 21 | setEdit(false); 22 | setFullNameClient(""); 23 | setEmailClient(""); 24 | }; 25 | return ( 26 |
27 |

User Information

28 | {!edit ? ( 29 | <> 30 |
31 | 32 |

33 | {fullName ? fullName : "N/A"} 34 |

35 |
36 |
37 | 38 |

{email}

39 |
40 | 47 | 48 | ) : ( 49 |
50 |
51 | 52 | setFullNameClient(e.target.value)} 59 | /> 60 |
61 |
62 | 63 | setEmailClient(e.target.value)} 71 | /> 72 |
73 |
74 | 87 | 95 |
96 |
97 | )} 98 |
99 | ); 100 | } 101 | 102 | export default MyProfile; 103 | -------------------------------------------------------------------------------- /frontend/components/nav-link.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Link from "next/link"; 3 | import { 4 | Bell, 5 | } from "lucide-react"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Switch } from "@/components/ui/switch"; 8 | import Image from "next/image"; 9 | import { usePathname } from "next/navigation"; 10 | import { useState } from "react"; 11 | import MainNavItems from "./main-nav-items"; 12 | import ChatHistory from "./chat-history"; 13 | // import { 14 | // Card, 15 | // CardContent, 16 | // CardDescription, 17 | // CardHeader, 18 | // CardTitle, 19 | // } from "@/components/ui/card"; 20 | 21 | function NavLink({ user }: { user: { email: string, chatList?: any } }) { 22 | const pathname = usePathname(); 23 | const [ showSecondNav, setShowSecondNav ] = useState(false); 24 | const handleToggleSwitch = () => { 25 | setShowSecondNav((prev: boolean) => !prev); 26 | console.log(showSecondNav); 27 | console.log(pathname); 28 | }; 29 | 30 | return ( 31 |
32 |
33 |
34 | 38 | EuclideanAI 44 | EuclideanAI 45 | 46 | 50 |
51 |
52 | {!showSecondNav? : } 53 |
54 |
55 |
56 | 57 |

Show Chat History

58 |
59 |

logged in as:

60 |

{user.email}

61 | {/* 62 | 63 | Upgrade to Pro 64 | 65 | Unlock all features and get unlimited access to our support 66 | team. 67 | 68 | 69 | 70 | 73 | 74 | */} 75 |
76 |
77 |
78 | ); 79 | } 80 | 81 | export default NavLink; 82 | -------------------------------------------------------------------------------- /frontend/components/oauth-login.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Github, Mail } from "lucide-react"; 4 | 5 | export function OAuthLogin() { 6 | async function loginGoogle() { 7 | const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; 8 | const GOOGLE_REDIRECT_URI = process.env.NEXT_PUBLIC_GOOGLE_REDIRECT_URI; 9 | 10 | if (!GOOGLE_CLIENT_ID || !GOOGLE_REDIRECT_URI) { 11 | console.error("Google Client ID or Redirect URI is not defined"); 12 | } 13 | const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${GOOGLE_REDIRECT_URI}&response_type=code&scope=email%20profile`; 14 | window.location.href = googleAuthUrl; 15 | } 16 | async function loginGithub() { 17 | const GITHUB_CLIENT_ID = process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID; 18 | const GITHUB_REDIRECT_URI = process.env.NEXT_PUBLIC_GITHUB_REDIRECT_URI; 19 | 20 | if (!GITHUB_CLIENT_ID || !GITHUB_REDIRECT_URI) { 21 | console.error("GitHub Client ID or Redirect URI is not defined"); 22 | } 23 | const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&scope=read:user user:email`; 24 | window.location.href = githubAuthUrl; 25 | } 26 | return ( 27 |
28 | 50 | 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /frontend/components/table-row-item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Image from "next/image"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Popover, 6 | PopoverContent, 7 | PopoverTrigger, 8 | } from "@/components/ui/popover"; 9 | import { TableCell, TableRow } from "@/components/ui/table"; 10 | import { DeleteItemDialog } from "@/components/delete-item-dialog"; 11 | import { EditItemDialog } from "@/components/edit-item-dialog"; 12 | import { useState } from "react"; 13 | 14 | function TableRowItem({ 15 | item, 16 | }: { 17 | item: { 18 | title: string; 19 | description?: string | null | undefined; 20 | id: number; 21 | owner_id: number; 22 | }; 23 | }) { 24 | const [popOverOpen, popOverSetOpen] = useState(false); 25 | return ( 26 | 27 | {item.id} 28 | {item.title} 29 | {item.description} 30 | 31 | 32 | 33 | 41 | 42 | 43 |
44 | 45 | 46 | 47 |
48 |
49 |
50 |
51 | ); 52 | } 53 | 54 | export default TableRowItem; 55 | -------------------------------------------------------------------------------- /frontend/components/table-row-user.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import Image from "next/image"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Popover, 6 | PopoverContent, 7 | PopoverTrigger, 8 | } from "@/components/ui/popover"; 9 | import { TableCell, TableRow } from "@/components/ui/table"; 10 | import { DeleteUserDialog } from "./delete-user-dialog"; 11 | import { EditUserDialog } from "./edit-user-dialog"; 12 | import { useState } from "react"; 13 | import { Dot } from "lucide-react"; 14 | 15 | function TableRowUser({ 16 | user, 17 | me, 18 | }: { 19 | user: { 20 | email: string; 21 | is_active?: boolean | undefined; 22 | is_superuser?: boolean | undefined; 23 | full_name?: string | null | undefined; 24 | id: number; 25 | }, 26 | me: { 27 | email: string; 28 | is_active?: boolean | undefined; 29 | is_superuser?: boolean | undefined; 30 | full_name?: string | null | undefined; 31 | id: number; 32 | }; 33 | }) { 34 | const [popOverOpen, popOverSetOpen] = useState(false); 35 | return ( 36 | 37 | 38 |
39 |

{`${user.full_name ? user.full_name : "N/A"}`}

40 | {user.email === me.email &&

YOU

} 41 |
42 |
43 | {user.email} 44 | {`${user.is_superuser ? "Superuser" : "User"}`} 45 | 46 |
47 | 48 | {`${user.is_active ? "Active" : "Inactive"}`} 49 |
50 |
51 | 52 | 53 | 54 | 62 | 63 | 64 |
65 | 69 | 73 | 74 |
75 |
76 |
77 |
78 | ); 79 | } 80 | 81 | export default TableRowUser; 82 | -------------------------------------------------------------------------------- /frontend/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | import { type ThemeProviderProps } from "next-themes/dist/types" 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /frontend/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /frontend/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /frontend/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |

56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |

64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /frontend/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /frontend/components/ui/drawer.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Drawer as DrawerPrimitive } from "vaul" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Drawer = ({ 9 | shouldScaleBackground = true, 10 | ...props 11 | }: React.ComponentProps) => ( 12 | 16 | ) 17 | Drawer.displayName = "Drawer" 18 | 19 | const DrawerTrigger = DrawerPrimitive.Trigger 20 | 21 | const DrawerPortal = DrawerPrimitive.Portal 22 | 23 | const DrawerClose = DrawerPrimitive.Close 24 | 25 | const DrawerOverlay = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 34 | )) 35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName 36 | 37 | const DrawerContent = React.forwardRef< 38 | React.ElementRef, 39 | React.ComponentPropsWithoutRef 40 | >(({ className, children, ...props }, ref) => ( 41 | 42 | 43 | 51 |
52 | {children} 53 | 54 | 55 | )) 56 | DrawerContent.displayName = "DrawerContent" 57 | 58 | const DrawerHeader = ({ 59 | className, 60 | ...props 61 | }: React.HTMLAttributes) => ( 62 |
66 | ) 67 | DrawerHeader.displayName = "DrawerHeader" 68 | 69 | const DrawerFooter = ({ 70 | className, 71 | ...props 72 | }: React.HTMLAttributes) => ( 73 |
77 | ) 78 | DrawerFooter.displayName = "DrawerFooter" 79 | 80 | const DrawerTitle = React.forwardRef< 81 | React.ElementRef, 82 | React.ComponentPropsWithoutRef 83 | >(({ className, ...props }, ref) => ( 84 | 92 | )) 93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName 94 | 95 | const DrawerDescription = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, ...props }, ref) => ( 99 | 104 | )) 105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName 106 | 107 | export { 108 | Drawer, 109 | DrawerPortal, 110 | DrawerOverlay, 111 | DrawerTrigger, 112 | DrawerClose, 113 | DrawerContent, 114 | DrawerHeader, 115 | DrawerFooter, 116 | DrawerTitle, 117 | DrawerDescription, 118 | } 119 | -------------------------------------------------------------------------------- /frontend/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /frontend/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } 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< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /frontend/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /frontend/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 5 | import { Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const RadioGroup = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => { 13 | return ( 14 | 19 | ) 20 | }) 21 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName 22 | 23 | const RadioGroupItem = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => { 27 | return ( 28 | 36 | 37 | 38 | 39 | 40 | ) 41 | }) 42 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName 43 | 44 | export { RadioGroup, RadioGroupItem } 45 | -------------------------------------------------------------------------------- /frontend/components/ui/submit-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFormStatus } from "react-dom"; 4 | import { type ComponentProps } from "react"; 5 | import { Button } from "@/components/ui/button"; 6 | 7 | type Props = ComponentProps<"button"> & { 8 | pendingText?: string; 9 | variant?: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined; 10 | }; 11 | 12 | export function SubmitButton({ children, pendingText, ...props }: Props) { 13 | const { pending, action } = useFormStatus(); 14 | 15 | const isPending = pending && action === props.formAction; 16 | 17 | return ( 18 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /frontend/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SwitchPrimitives from "@radix-ui/react-switch" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 25 | 26 | )) 27 | Switch.displayName = SwitchPrimitives.Root.displayName 28 | 29 | export { Switch } 30 | -------------------------------------------------------------------------------- /frontend/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | 93 | )) 94 | TableCell.displayName = "TableCell" 95 | 96 | const TableCaption = React.forwardRef< 97 | HTMLTableCaptionElement, 98 | React.HTMLAttributes 99 | >(({ className, ...props }, ref) => ( 100 |
105 | )) 106 | TableCaption.displayName = "TableCaption" 107 | 108 | export { 109 | Table, 110 | TableHeader, 111 | TableBody, 112 | TableFooter, 113 | TableHead, 114 | TableRow, 115 | TableCell, 116 | TableCaption, 117 | } 118 | -------------------------------------------------------------------------------- /frontend/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /frontend/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |