├── README.md ├── compose.yml └── packages ├── api ├── .DS_Store ├── .dockerignore ├── .env ├── .gitignore ├── .vscode │ └── launch.json ├── Dockerfile-dev ├── docs │ └── collection.paw ├── pyproject.toml ├── requirements.txt └── src │ ├── .DS_Store │ ├── category │ ├── application │ │ └── use_case │ │ │ ├── command.py │ │ │ └── query.py │ ├── domain │ │ ├── entity.py │ │ └── exceptions.py │ ├── infra │ │ ├── database │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ └── __init__.py │ │ │ ├── models.py │ │ │ └── repository │ │ │ │ ├── mapper.py │ │ │ │ └── rdb.py │ │ └── django │ │ │ ├── admin.py │ │ │ └── apps.py │ └── presentation │ │ └── rest │ │ ├── api.py │ │ ├── containers.py │ │ ├── request.py │ │ └── response.py │ ├── flashcard │ ├── application │ │ └── use_case │ │ │ ├── command.py │ │ │ └── query.py │ ├── domain │ │ ├── entity.py │ │ └── exceptions.py │ ├── infra │ │ ├── database │ │ │ ├── migrations │ │ │ │ ├── 0001_initial.py │ │ │ │ └── __init__.py │ │ │ ├── models.py │ │ │ └── repository │ │ │ │ ├── mapper.py │ │ │ │ └── rdb.py │ │ └── django │ │ │ ├── admin.py │ │ │ └── apps.py │ └── presentation │ │ └── rest │ │ ├── api.py │ │ ├── containers.py │ │ ├── request.py │ │ └── response.py │ ├── manage.py │ └── shared │ ├── domain │ ├── entity.py │ └── exception.py │ ├── infra │ ├── django │ │ ├── asgi.py │ │ ├── settings.py │ │ └── wsgi.py │ └── repository │ │ ├── cursor_wrapper.py │ │ ├── mapper.py │ │ └── rdb.py │ └── presentation │ └── rest │ ├── api.py │ └── response.py └── web ├── .eslintrc.json ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── Dockerfile-dev ├── Dockerfile-prod ├── components.json ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public └── favicon.ico ├── src ├── app │ ├── api │ │ ├── categories │ │ │ ├── [id] │ │ │ │ ├── flashcards │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── flashcards │ │ │ ├── [id] │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── populate-database │ │ │ └── route.ts │ │ └── wipe-database │ │ │ └── route.ts │ ├── database-operations.tsx │ ├── globals.css │ ├── layout.tsx │ ├── manage │ │ ├── _categories │ │ │ ├── categories-columns.tsx │ │ │ ├── categories-data-table.tsx │ │ │ ├── confirm-category-delete.tsx │ │ │ ├── create-category-dialog.tsx │ │ │ └── edit-category-dialog.tsx │ │ ├── _flashcards │ │ │ ├── confirm-flashcard-delete.tsx │ │ │ ├── create-flashcard-dialog.tsx │ │ │ ├── edit-flashcard-dialog.tsx │ │ │ ├── flashcards-columns.tsx │ │ │ ├── flashcards-data-table.tsx │ │ │ └── select-category.tsx │ │ └── page.tsx │ ├── practice │ │ ├── flashcards.tsx │ │ ├── page.tsx │ │ └── practice-session.tsx │ └── providers.tsx ├── components │ └── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── hidden-input.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── select.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── table.tsx │ │ ├── textarea.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx └── lib │ ├── models.ts │ └── utils.ts ├── tailwind.config.ts ├── tsconfig.json └── utils ├── array.ts ├── color.ts └── wait.ts /README.md: -------------------------------------------------------------------------------- 1 | # Flashcards 🧠 2 | 3 | Flashcards is a simple CRUD app that allows users to create their own flashcards and categories, and lets them practice by showing them the cards. This project is part of a workshop we're doing at [Sentry.io](https://sentry.io/welcome). 4 | 5 | ## Getting started 6 | 7 | - `docker compose up --build` 8 | - Create Sentry account 9 | - Create a Sentry Next.js project 10 | - Set up Sentry with the Next.js wizard: `npx @sentry/wizard@latest -i nextjs` 11 | - Create a Sentry Django project 12 | - Add Sentry's Django SDK in `requirements.txt`: `sentry-sdk[django]` 13 | - Configure the [Sentry Django SDK](https://docs.sentry.io/platforms/python/integrations/django/#configure) 14 | - Re-run `docker compose up --build` if needed 15 | 16 | ## Finding Bugs 17 | 18 | - Test out all the features of the Flashcards app 19 | - When you encounter an error or notice a performance issue, go check Sentry 20 | - It's more than likely your error has been picked up by Sentry 21 | - Click on the issue in your dashboard to see in depth details about where the issue happened, who it affected and what led to it. 22 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:16 4 | container_name: db 5 | restart: always 6 | volumes: 7 | - db-data:/var/lib/postgresql/data/ 8 | environment: 9 | - POSTGRES_USER=postgres 10 | - POSTGRES_PASSWORD=password 11 | - POSTGRES_DB=flashcards 12 | ports: 13 | - '5432:5432' 14 | networks: 15 | - app-network 16 | healthcheck: 17 | test: ['CMD-SHELL', 'pg_isready -U postgres'] 18 | interval: 5s 19 | timeout: 5s 20 | retries: 5 21 | 22 | migration: 23 | container_name: migration 24 | build: 25 | context: ./packages/api 26 | dockerfile: Dockerfile-dev 27 | command: python src/manage.py migrate 28 | environment: 29 | - DB_NAME=flashcards 30 | - DB_USER=postgres 31 | - DB_PASSWORD=password 32 | - DB_HOST=db 33 | - DB_PORT=5432 34 | networks: 35 | - app-network 36 | depends_on: 37 | db: 38 | condition: service_healthy 39 | 40 | api: 41 | container_name: api 42 | build: 43 | context: ./packages/api 44 | dockerfile: Dockerfile-dev 45 | ports: 46 | - '8000:8000' 47 | volumes: 48 | - ./packages/api:/app 49 | networks: 50 | - app-network 51 | environment: 52 | - DB_HOST=db 53 | 54 | depends_on: 55 | db: 56 | condition: service_healthy 57 | migration: 58 | condition: service_completed_successfully 59 | 60 | ## Development Web build 61 | web: 62 | container_name: web 63 | build: 64 | context: ./packages/web 65 | dockerfile: Dockerfile-dev 66 | ports: 67 | - '3000:3000' 68 | volumes: 69 | - ./packages/web:/app 70 | - web-node-modules:/app/node_modules 71 | environment: 72 | - PORT=3000 73 | networks: 74 | - app-network 75 | depends_on: 76 | db: 77 | condition: service_healthy 78 | migration: 79 | condition: service_completed_successfully 80 | 81 | ## Production Web build 82 | # web: 83 | # container_name: web 84 | # build: 85 | # context: ./packages/web 86 | # dockerfile: Dockerfile-prod 87 | # ports: 88 | # - "3000:3000" 89 | # volumes: 90 | # - ./packages/web:/app 91 | # - web-node-modules:/app/node_modules 92 | # environment: 93 | # - PORT=3000 94 | # networks: 95 | # - app-network 96 | # depends_on: 97 | # db: 98 | # condition: service_healthy 99 | # migration: 100 | # condition: service_completed_successfully 101 | 102 | networks: 103 | app-network: 104 | driver: bridge 105 | 106 | volumes: 107 | db-data: 108 | web-node-modules: 109 | -------------------------------------------------------------------------------- /packages/api/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/flashcards/b87fe5300f142c0779d0960a9254dc42cd85ad79/packages/api/.DS_Store -------------------------------------------------------------------------------- /packages/api/.dockerignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /packages/api/.env: -------------------------------------------------------------------------------- 1 | GROQ_API_KEY= # Generate new 2 | DEBUG=1 3 | DB_NAME=flashcards 4 | DB_USER=postgres 5 | DB_PASSWORD=password 6 | DB_HOST=localhost 7 | DB_PORT=5432 8 | -------------------------------------------------------------------------------- /packages/api/.gitignore: -------------------------------------------------------------------------------- 1 | # Environment 2 | .venv 3 | 4 | # Byte-compiled / optimized / DLL files 5 | **/__pycache__ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | dist/ 13 | *.egg-info 14 | *.egg 15 | -------------------------------------------------------------------------------- /packages/api/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug API", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "stopOnEntry": false, 9 | "program": "${workspaceRoot}/src/manage.py", 10 | "args": ["runserver", "--no-color", "--noreload"] 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /packages/api/Dockerfile-dev: -------------------------------------------------------------------------------- 1 | # Use official Python runtime as base image 2 | FROM python:3.11-slim 3 | 4 | # Set environment variables 5 | ENV PYTHONDONTWRITEBYTECODE=1 6 | ENV PYTHONUNBUFFERED=1 7 | 8 | # Set work directory 9 | WORKDIR /app 10 | 11 | # Install system dependencies 12 | RUN apt-get update && apt-get install -y \ 13 | gcc \ 14 | libpq-dev \ 15 | python3-dev \ 16 | postgresql-client \ 17 | && rm -rf /var/lib/apt/lists/* 18 | 19 | # Install Python dependencies 20 | COPY requirements.txt . 21 | RUN pip install --no-cache-dir -r requirements.txt 22 | 23 | # Copy project files 24 | COPY . . 25 | 26 | # Expose port 27 | EXPOSE 8000 28 | 29 | # Start development server 30 | CMD ["python", "src/manage.py", "runserver", "0.0.0.0:8000"] 31 | -------------------------------------------------------------------------------- /packages/api/docs/collection.paw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/flashcards/b87fe5300f142c0779d0960a9254dc42cd85ad79/packages/api/docs/collection.paw -------------------------------------------------------------------------------- /packages/api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | # Same as Black. 3 | line-length = 80 4 | indent-width = 4 5 | 6 | # Enable the isort rule 7 | extend-select = ["I"] -------------------------------------------------------------------------------- /packages/api/requirements.txt: -------------------------------------------------------------------------------- 1 | asgiref==3.8.1 2 | Django==5.1.1 3 | django-environ==0.11.2 4 | django-ninja==1.3.0 5 | groq==0.11.0 6 | typing_extensions==4.12.2 7 | pytest==8.3.3 8 | ruff==0.6.9 9 | psycopg2-binary>=2.9.9 -------------------------------------------------------------------------------- /packages/api/src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/flashcards/b87fe5300f142c0779d0960a9254dc42cd85ad79/packages/api/src/.DS_Store -------------------------------------------------------------------------------- /packages/api/src/category/application/use_case/command.py: -------------------------------------------------------------------------------- 1 | 2 | from category.domain.entity import Category 3 | from category.infra.database.repository.rdb import CategoryRepository 4 | 5 | 6 | class CategoryCommand: 7 | category_repository: CategoryRepository 8 | 9 | def __init__(self, category_repository: CategoryRepository): 10 | self.category_repository = category_repository 11 | 12 | def create_category(self, name: str) -> Category: 13 | category: Category = Category.new(name=name) 14 | return self.category_repository.save(entity=category) 15 | 16 | def update_category(self, category: Category, name: str | None) -> Category: 17 | if name: 18 | category.update_name(name=name) 19 | 20 | return self.category_repository.save(entity=category) 21 | 22 | def delete_category(self, category_id: int) -> None: 23 | self.category_repository.delete_category(category_id=category_id) 24 | -------------------------------------------------------------------------------- /packages/api/src/category/application/use_case/query.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from category.domain.entity import Category 4 | from category.infra.database.repository.rdb import CategoryRepository 5 | from flashcard.domain.entity import Flashcard 6 | from flashcard.infra.database.models import Flashcard as FlashcardModel 7 | from flashcard.infra.database.repository.rdb import FlashcardRepository 8 | 9 | 10 | class CategoryQuery: 11 | category_repository: CategoryRepository 12 | flashcard_repository: FlashcardRepository 13 | 14 | def __init__(self, category_repository: CategoryRepository, flashcard_repository: FlashcardRepository): 15 | self.category_repository = category_repository 16 | self.flashcard_repository = flashcard_repository 17 | 18 | def get_all_categories(self) -> List[Category]: 19 | return self.category_repository.find_all() 20 | 21 | def get_category(self, id: int) -> Category: 22 | return self.category_repository.find_by_id(id) 23 | 24 | def get_flashcards_by_category(self, category_id: int) -> List[Flashcard]: 25 | return self.flashcard_repository.find_by_category(category_id=category_id) -------------------------------------------------------------------------------- /packages/api/src/category/domain/entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from django.utils.text import slugify 6 | 7 | from shared.domain.entity import Entity 8 | 9 | 10 | @dataclass(eq=False) 11 | class Category(Entity): 12 | name: str 13 | slug: str 14 | 15 | @classmethod 16 | def new(cls, name: str) -> Category: 17 | return cls(name=name, slug=slugify(name)) 18 | 19 | def update_name(self, name: str) -> None: 20 | self.name = name 21 | self.slug = slugify(name) -------------------------------------------------------------------------------- /packages/api/src/category/domain/exceptions.py: -------------------------------------------------------------------------------- 1 | from shared.domain.exception import BaseMsgException 2 | 3 | 4 | class CategoryNameTooShort(BaseMsgException): 5 | message = "Category name must be at least 5 characters long" 6 | 7 | class CategoryNotFoundError(BaseMsgException): 8 | message = "Category not found" 9 | 10 | class CategoryExistsError(BaseMsgException): 11 | message = "Category already exists" 12 | -------------------------------------------------------------------------------- /packages/api/src/category/infra/database/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.1 on 2024-10-09 15:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Category", 15 | fields=[ 16 | ( 17 | "id", 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=255)), 26 | ("slug", models.SlugField(blank=True, unique=True)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /packages/api/src/category/infra/database/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/flashcards/b87fe5300f142c0779d0960a9254dc42cd85ad79/packages/api/src/category/infra/database/migrations/__init__.py -------------------------------------------------------------------------------- /packages/api/src/category/infra/database/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.text import slugify 3 | 4 | from category.domain.exceptions import CategoryNameTooShort 5 | 6 | 7 | class Category(models.Model): 8 | name = models.CharField(max_length=255) 9 | slug = models.SlugField(unique=True, blank=True) 10 | 11 | def clean(self) -> None: 12 | if len(self.name) < 6: 13 | raise CategoryNameTooShort() 14 | self.slug = slugify(self.name) 15 | 16 | return super().clean() 17 | 18 | def save(self, *args, **kwargs): 19 | self.clean() 20 | return super().save(*args, **kwargs) 21 | 22 | def delete(self, *args, **kwargs): 23 | return super().delete(*args, **kwargs) 24 | 25 | def __str__(self): 26 | return self.name 27 | 28 | -------------------------------------------------------------------------------- /packages/api/src/category/infra/database/repository/mapper.py: -------------------------------------------------------------------------------- 1 | from category.domain.entity import Category as CategoryEntity 2 | from category.infra.database.models import Category as CategoryModel 3 | from shared.infra.repository.mapper import ModelMapperInterface 4 | 5 | 6 | class CategoryMapper(ModelMapperInterface): 7 | def to_entity(self, instance: CategoryModel) -> CategoryEntity: 8 | return CategoryEntity( 9 | id=instance.id, 10 | name=instance.name, 11 | slug=instance.slug 12 | ) 13 | 14 | def to_instance(self, entity: CategoryEntity) -> CategoryModel: 15 | return CategoryModel( 16 | id=entity.id, 17 | name=entity.name, 18 | slug=entity.slug 19 | ) -------------------------------------------------------------------------------- /packages/api/src/category/infra/database/repository/rdb.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | from category.domain.exceptions import CategoryNotFoundError 4 | from category.infra.database.models import Category as CategoryModel 5 | from category.infra.database.repository.mapper import CategoryMapper 6 | from shared.infra.repository.rdb import RDBRepository 7 | 8 | 9 | class CategoryRepository(RDBRepository): 10 | model_mapper: CategoryMapper 11 | 12 | def __init__(self, model_mapper: CategoryMapper = CategoryMapper()): 13 | self.model_mapper = model_mapper 14 | 15 | def find_all(self): 16 | return self.model_mapper.to_entity_list(CategoryModel.objects.all()) 17 | 18 | def find_by_id(self, id: int): 19 | return self.model_mapper.to_entity(CategoryModel.objects.get(id=id)) 20 | 21 | @staticmethod 22 | def delete_category(category_id: int) -> None: 23 | try: 24 | category = CategoryModel.objects.get(id=category_id) 25 | category.delete() 26 | except CategoryModel.DoesNotExist: 27 | raise CategoryNotFoundError 28 | 29 | -------------------------------------------------------------------------------- /packages/api/src/category/infra/django/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from category.domain.entity import Category 4 | 5 | admin.site.register(Category) 6 | -------------------------------------------------------------------------------- /packages/api/src/category/infra/django/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CategoryConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "category" 7 | 8 | def ready(self): 9 | import shared.infra.repository.cursor_wrapper 10 | -------------------------------------------------------------------------------- /packages/api/src/category/presentation/rest/api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from ninja import Router 4 | 5 | from category.domain.entity import Category 6 | from category.domain.exceptions import ( 7 | CategoryNameTooShort, 8 | CategoryNotFoundError, 9 | ) 10 | from category.presentation.rest.containers import ( 11 | category_command, 12 | category_query, 13 | ) 14 | from category.presentation.rest.request import ( 15 | PatchCategoryRequestBody, 16 | PostCategoryRequestBody, 17 | ) 18 | from category.presentation.rest.response import ( 19 | CategoryResponse, 20 | ListCategoryResponse, 21 | ) 22 | from flashcard.domain.exceptions import FlashcardNotFoundError 23 | from flashcard.presentation.rest.response import ListFlashcardResponse 24 | from shared.domain.exception import ModelExistsError 25 | from shared.presentation.rest.response import ( 26 | ErrorMessageResponse, 27 | ObjectResponse, 28 | error_response, 29 | response, 30 | ) 31 | 32 | router = Router(tags=["categories"]) 33 | 34 | 35 | @router.get( 36 | "", 37 | response={ 38 | 200: ObjectResponse[ListCategoryResponse], 39 | }, 40 | ) 41 | def get_all_categories(request): 42 | categories: List[Category] = category_query.get_all_categories() 43 | return 200, response(ListCategoryResponse.build(categories=categories)) 44 | 45 | 46 | @router.get( 47 | "/{category_id}", 48 | response={ 49 | 200: ObjectResponse[CategoryResponse], 50 | 404: ObjectResponse[ErrorMessageResponse], 51 | }, 52 | ) 53 | def get_category(request, category_id: int): 54 | try: 55 | category = category_query.get_category(id=category_id) 56 | except CategoryNotFoundError as e: 57 | return 404, error_response(str(e)) 58 | 59 | return 200, response(CategoryResponse.build(category=category)) 60 | 61 | 62 | @router.post( 63 | "", 64 | response={ 65 | 201: ObjectResponse[CategoryResponse], 66 | 400: ObjectResponse[ErrorMessageResponse], 67 | }, 68 | ) 69 | def create_category(request, body: PostCategoryRequestBody): 70 | try: 71 | category = category_command.create_category(name=body.name) 72 | return 201, response(CategoryResponse.build(category=category)) 73 | except ModelExistsError: 74 | return 400, error_response("Category already exists") 75 | 76 | 77 | @router.patch( 78 | "/{category_id}", 79 | response={ 80 | 200: ObjectResponse[CategoryResponse], 81 | 404: ObjectResponse[ErrorMessageResponse], 82 | }, 83 | ) 84 | def update_category(request, category_id: int, body: PatchCategoryRequestBody): 85 | try: 86 | category = category_query.get_category(id=category_id) 87 | except CategoryNotFoundError as e: 88 | return 404, error_response(str(e)) 89 | 90 | try: 91 | category = category_command.update_category( 92 | category=category, name=body.name 93 | ) 94 | except CategoryNotFoundError as e: 95 | return 404, error_response(str(e)) 96 | 97 | return 200, response(CategoryResponse.build(category=category)) 98 | 99 | 100 | @router.delete( 101 | "/{category_id}", 102 | response={ 103 | 204: None, 104 | 404: ObjectResponse[ErrorMessageResponse], 105 | }, 106 | ) 107 | def delete_category(request, category_id: int): 108 | try: 109 | category_command.delete_category(category_id=category_id) 110 | except CategoryNotFoundError as e: 111 | return 404, error_response(str(e)) 112 | 113 | return 204, None 114 | 115 | 116 | @router.get( 117 | "/{category_id}/flashcards", 118 | response={ 119 | 200: ObjectResponse[ListFlashcardResponse], 120 | 404: ObjectResponse[ErrorMessageResponse], 121 | }, 122 | ) 123 | def get_flashcards_by_category(request, category_id: int): 124 | try: 125 | category = category_query.get_category(id=category_id) 126 | except CategoryNotFoundError as e: 127 | return 404, error_response(str(e)) 128 | 129 | try: 130 | assert category.id 131 | flashcards = category_query.get_flashcards_by_category( 132 | category_id=category.id 133 | ) 134 | except FlashcardNotFoundError as e: 135 | return 404, error_response(str(e)) 136 | 137 | return 200, response(ListFlashcardResponse.build(flashcards=flashcards)) 138 | -------------------------------------------------------------------------------- /packages/api/src/category/presentation/rest/containers.py: -------------------------------------------------------------------------------- 1 | from category.application.use_case.command import CategoryCommand 2 | from category.application.use_case.query import CategoryQuery 3 | from category.infra.database.repository.rdb import CategoryRepository 4 | from flashcard.infra.database.repository.rdb import FlashcardRepository 5 | 6 | flashcard_repo: FlashcardRepository = FlashcardRepository() 7 | 8 | category_repo: CategoryRepository = CategoryRepository() 9 | 10 | category_query: CategoryQuery = CategoryQuery(category_repository=category_repo, flashcard_repository=flashcard_repo) 11 | category_command: CategoryCommand = CategoryCommand(category_repository=category_repo) 12 | -------------------------------------------------------------------------------- /packages/api/src/category/presentation/rest/request.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class PostCategoryRequestBody(Schema): 5 | name: str 6 | 7 | 8 | class PatchCategoryRequestBody(Schema): 9 | name: str | None 10 | -------------------------------------------------------------------------------- /packages/api/src/category/presentation/rest/response.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from ninja import Schema 4 | 5 | from category.domain.entity import Category 6 | 7 | 8 | class CategorySchema(Schema): 9 | id: int 10 | name: str 11 | slug: str 12 | 13 | class CategoryResponse(Schema): 14 | category: CategorySchema 15 | 16 | @classmethod 17 | def build(cls, category: Category) -> dict: 18 | return cls(category=CategorySchema(id=category.id, name=category.name, slug=category.slug)).model_dump() 19 | 20 | class ListCategoryResponse(Schema): 21 | categories: List[CategorySchema] 22 | 23 | @classmethod 24 | def build(cls, categories: List[Category]) -> dict: 25 | return cls( 26 | categories = [ 27 | CategorySchema(id=category.id, name=category.name, slug=category.slug) 28 | for category in categories 29 | ] 30 | ).model_dump() 31 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/application/use_case/command.py: -------------------------------------------------------------------------------- 1 | from category.domain.entity import Category 2 | from flashcard.domain.entity import Flashcard 3 | from flashcard.infra.database.repository.rdb import FlashcardRepository 4 | 5 | 6 | class FlashcardCommand: 7 | flashcard_repository: FlashcardRepository 8 | 9 | def __init__(self, flashcard_repository: FlashcardRepository): 10 | self.flashcard_repository = flashcard_repository 11 | 12 | def create_flashcard(self, question: str, answer: str, category: Category) -> Flashcard: 13 | flashcard: Flashcard = Flashcard.new(question=question, answer=answer, category=category) 14 | return self.flashcard_repository.save(entity=flashcard) 15 | 16 | def update_flashcard(self, flashcard: Flashcard, question: str | None, answer: str | None, category: Category | None) -> Flashcard: 17 | if question: 18 | flashcard.update_question(question=question) 19 | if answer: 20 | flashcard.update_answer(answer=answer) 21 | if category: 22 | flashcard.update_category(category=category) 23 | 24 | return self.flashcard_repository.save(entity=flashcard) 25 | 26 | def delete_flashcard(self, flashcard_id: int) -> None: 27 | self.flashcard_repository.delete_flashcard(flashcard_id=flashcard_id) 28 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/application/use_case/query.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from flashcard.domain.entity import Flashcard 4 | from flashcard.infra.database.repository.rdb import FlashcardRepository 5 | 6 | 7 | class FlashcardQuery: 8 | flashcard_repository: FlashcardRepository 9 | 10 | def __init__(self, flashcard_repository: FlashcardRepository): 11 | self.flashcard_repository = flashcard_repository 12 | 13 | def get_all_flashcards(self) -> List[Flashcard]: 14 | return self.flashcard_repository.find_all() 15 | 16 | def get_flashcard(self, id: int) -> Flashcard: 17 | return self.flashcard_repository.find_by_id(id) 18 | 19 | def get_flashcards_by_category(self, category_id: int) -> List[Flashcard]: 20 | return self.flashcard_repository.find_by_category(category_id=category_id) 21 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/domain/entity.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | 5 | from django.utils.text import slugify 6 | 7 | from category.domain.entity import Category 8 | from shared.domain.entity import Entity 9 | 10 | 11 | @dataclass(eq=False) 12 | class Flashcard(Entity): 13 | question: str 14 | answer: str 15 | category: Category 16 | slug: str 17 | 18 | @classmethod 19 | def new(cls, question: str, answer: str, category: Category) -> Flashcard: 20 | return cls(question=question, answer=answer, category=category, slug=slugify(question)) 21 | 22 | def update_answer(self, answer: str) -> None: 23 | self.answer = answer 24 | 25 | def update_question(self, question: str) -> None: 26 | self.question = question 27 | self.slug = slugify(question) 28 | 29 | def update_category(self, category: Category) -> None: 30 | self.category = category 31 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/domain/exceptions.py: -------------------------------------------------------------------------------- 1 | from shared.domain.exception import BaseMsgException 2 | 3 | 4 | class FlashcardNotFoundError(BaseMsgException): 5 | message = "Flashcard not found" 6 | 7 | class FlashcardExistsError(BaseMsgException): 8 | message = "Flashcard already exists" 9 | 10 | 11 | class FlashcardQuestionTooShort(BaseMsgException): 12 | message = "Flashcard question must be at least 5 characters long" 13 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/infra/database/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.1 on 2024-10-09 20:54 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ("category", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Flashcard", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("question", models.TextField()), 29 | ("answer", models.TextField()), 30 | ("slug", models.SlugField(blank=True, unique=True)), 31 | ( 32 | "category", 33 | models.ForeignKey( 34 | on_delete=django.db.models.deletion.CASCADE, 35 | to="category.category", 36 | ), 37 | ), 38 | ], 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/infra/database/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/flashcards/b87fe5300f142c0779d0960a9254dc42cd85ad79/packages/api/src/flashcard/infra/database/migrations/__init__.py -------------------------------------------------------------------------------- /packages/api/src/flashcard/infra/database/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.text import slugify 3 | 4 | from category.infra.database.models import Category as CategoryModel 5 | from flashcard.domain.exceptions import FlashcardQuestionTooShort 6 | 7 | 8 | class Flashcard(models.Model): 9 | question = models.CharField(max_length=255) 10 | answer = models.TextField() 11 | category = models.ForeignKey(CategoryModel, on_delete=models.CASCADE) 12 | slug = models.SlugField(unique=True, blank=True) 13 | 14 | def clean(self) -> None: 15 | if len(self.question) < 5: 16 | raise FlashcardQuestionTooShort() 17 | self.slug = slugify(self.question) 18 | 19 | return super().clean() 20 | 21 | def save(self, *args, **kwargs): 22 | self.clean() 23 | return super().save(*args, **kwargs) 24 | 25 | def delete(self, *args, **kwargs): 26 | super().delete(*args, **kwargs) 27 | 28 | def __str__(self): 29 | return self.question 30 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/infra/database/repository/mapper.py: -------------------------------------------------------------------------------- 1 | from category.domain.entity import Category as CategoryEntity 2 | from flashcard.domain.entity import Flashcard as FlashcardEntity 3 | from flashcard.infra.database.models import Flashcard as FlashcardModel 4 | from shared.infra.repository.mapper import ModelMapperInterface 5 | 6 | 7 | class FlashcardMapper(ModelMapperInterface): 8 | def to_entity(self, instance: FlashcardModel) -> FlashcardEntity: 9 | category = CategoryEntity(id=instance.category_id, name=instance.category.name, slug=instance.category.slug) 10 | return FlashcardEntity( 11 | id=instance.id, 12 | question=instance.question, 13 | answer=instance.answer, 14 | category=category, 15 | slug=instance.slug 16 | ) 17 | 18 | def to_instance(self, entity: FlashcardEntity) -> FlashcardModel: 19 | return FlashcardModel( 20 | id=entity.id, 21 | question=entity.question, 22 | answer=entity.answer, 23 | category_id=entity.category.id, 24 | slug=entity.slug 25 | ) 26 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/infra/database/repository/rdb.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | from flashcard.domain.exceptions import FlashcardNotFoundError 4 | from flashcard.infra.database.models import Flashcard as FlashcardModel 5 | from flashcard.infra.database.repository.mapper import FlashcardMapper 6 | from shared.infra.repository.rdb import RDBRepository 7 | 8 | 9 | class FlashcardRepository(RDBRepository): 10 | model_mapper: FlashcardMapper 11 | 12 | def __init__(self, model_mapper: FlashcardMapper = FlashcardMapper()): 13 | self.model_mapper = model_mapper 14 | 15 | def find_all(self): 16 | return self.model_mapper.to_entity_list(FlashcardModel.objects.all()) 17 | 18 | def find_by_id(self, id: int): 19 | return self.model_mapper.to_entity(FlashcardModel.objects.get(id=id)) 20 | 21 | def find_by_category(self, category_id: int): 22 | return self.model_mapper.to_entity_list( 23 | FlashcardModel.objects.filter(category_id=category_id) 24 | ) 25 | 26 | @staticmethod 27 | def delete_flashcard(flashcard_id: int) -> None: 28 | try: 29 | flashcard = FlashcardModel.objects.get(id=flashcard_id) 30 | flashcard.delete() 31 | except FlashcardModel.DoesNotExist: 32 | raise FlashcardNotFoundError 33 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/infra/django/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from flashcard.domain.entity import Flashcard 4 | 5 | admin.site.register(Flashcard) 6 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/infra/django/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FlashcardConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "flashcard" 7 | 8 | def ready(self): 9 | import shared.infra.repository.cursor_wrapper 10 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/presentation/rest/api.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from ninja import Router 4 | 5 | from category.domain.exceptions import CategoryNotFoundError 6 | from category.presentation.rest.containers import category_query 7 | from flashcard.domain.entity import Flashcard 8 | from flashcard.domain.exceptions import ( 9 | FlashcardExistsError, 10 | FlashcardNotFoundError, 11 | FlashcardQuestionTooShort, 12 | ) 13 | from flashcard.presentation.rest.containers import ( 14 | flashcard_command, 15 | flashcard_query, 16 | ) 17 | from flashcard.presentation.rest.request import ( 18 | PatchFlashcardRequestBody, 19 | PostFlashcardRequestBody, 20 | ) 21 | from flashcard.presentation.rest.response import ( 22 | FlashcardResponse, 23 | ListFlashcardResponse, 24 | ) 25 | from shared.presentation.rest.response import ( 26 | ErrorMessageResponse, 27 | ObjectResponse, 28 | error_response, 29 | response, 30 | ) 31 | 32 | router = Router(tags=["flashcards"]) 33 | 34 | @router.get( 35 | "", 36 | response={ 37 | 200: ObjectResponse[ListFlashcardResponse], 38 | } 39 | ) 40 | def get_all_flashcards(request): 41 | flashcards: List[Flashcard] = flashcard_query.get_all_flashcards() 42 | return 200, response(ListFlashcardResponse.build(flashcards=flashcards)) 43 | 44 | @router.get( 45 | "/{flashcard_id}", 46 | response={ 47 | 200: ObjectResponse[FlashcardResponse], 48 | 404: ObjectResponse[ErrorMessageResponse], 49 | } 50 | ) 51 | def get_flashcard(request, flashcard_id: int): 52 | try: 53 | flashcard = flashcard_query.get_flashcard(id=flashcard_id) 54 | except FlashcardNotFoundError as e: 55 | return 404, error_response(str(e)) 56 | 57 | return 200, response(FlashcardResponse.build(flashcard=flashcard)) 58 | 59 | @router.post( 60 | "", 61 | response={ 62 | 201: ObjectResponse[FlashcardResponse], 63 | 400: ObjectResponse[ErrorMessageResponse], 64 | } 65 | ) 66 | def create_flashcard(request, body: PostFlashcardRequestBody): 67 | try: 68 | category = category_query.get_category(id=body.category_id) 69 | except CategoryNotFoundError: 70 | return 400, error_response("Category not found") 71 | 72 | try: 73 | flashcard = flashcard_command.create_flashcard( 74 | question=body.question, 75 | answer=body.answer, 76 | category=category 77 | ) 78 | return 201, response(FlashcardResponse.build(flashcard=flashcard)) 79 | except FlashcardQuestionTooShort: 80 | return 400, error_response("Question too short") 81 | except FlashcardExistsError: 82 | return 400, error_response("Flashcard already exists") 83 | 84 | @router.patch( 85 | "/{flashcard_id}", 86 | response={ 87 | 200: ObjectResponse[FlashcardResponse], 88 | 404: ObjectResponse[ErrorMessageResponse], 89 | } 90 | ) 91 | def update_flashcard(request, flashcard_id: int, body: PatchFlashcardRequestBody): 92 | try: 93 | flashcard = flashcard_query.get_flashcard(id=flashcard_id) 94 | except FlashcardNotFoundError: 95 | return 404, error_response("Flashcard not found") 96 | 97 | try: 98 | category = category_query.get_category(id=body.category_id) 99 | except CategoryNotFoundError: 100 | return 400, error_response("Category not found") 101 | 102 | flashcard = flashcard_command.update_flashcard( 103 | flashcard=flashcard, 104 | question=body.question, 105 | answer=body.answer, 106 | category=category 107 | ) 108 | 109 | return 200, response(FlashcardResponse.build(flashcard=flashcard)) 110 | 111 | @router.delete( 112 | "/{flashcard_id}", 113 | response={ 114 | 204: None, 115 | 404: ObjectResponse[ErrorMessageResponse], 116 | } 117 | ) 118 | def delete_flashcard(request, flashcard_id: int): 119 | try: 120 | flashcard_command.delete_flashcard(flashcard_id=flashcard_id) 121 | except FlashcardNotFoundError as e: 122 | return 404, error_response(str(e)) 123 | 124 | return 204, None 125 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/presentation/rest/containers.py: -------------------------------------------------------------------------------- 1 | from flashcard.application.use_case.command import FlashcardCommand 2 | from flashcard.application.use_case.query import FlashcardQuery 3 | from flashcard.infra.database.repository.rdb import FlashcardRepository 4 | 5 | flashcard_repo: FlashcardRepository = FlashcardRepository() 6 | 7 | flashcard_query: FlashcardQuery = FlashcardQuery(flashcard_repository=flashcard_repo) 8 | flashcard_command: FlashcardCommand = FlashcardCommand(flashcard_repository=flashcard_repo) 9 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/presentation/rest/request.py: -------------------------------------------------------------------------------- 1 | from ninja import Schema 2 | 3 | 4 | class PostFlashcardRequestBody(Schema): 5 | question: str 6 | answer: str 7 | category_id: int 8 | 9 | 10 | class PatchFlashcardRequestBody(Schema): 11 | question: str | None 12 | answer: str | None 13 | category_id: int | None 14 | -------------------------------------------------------------------------------- /packages/api/src/flashcard/presentation/rest/response.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from ninja import Schema 4 | 5 | from flashcard.domain.entity import Flashcard 6 | 7 | 8 | class FlashcardSchema(Schema): 9 | id: int 10 | question: str 11 | answer: str 12 | category_id: int 13 | slug: str 14 | 15 | class FlashcardResponse(Schema): 16 | flashcard: FlashcardSchema 17 | 18 | @classmethod 19 | def build(cls, flashcard: Flashcard) -> dict: 20 | return cls(flashcard=FlashcardSchema(id=flashcard.id, question=flashcard.question, answer=flashcard.answer, category_id=flashcard.category.id, slug=flashcard.slug)).model_dump() 21 | 22 | class ListFlashcardResponse(Schema): 23 | flashcards: List[FlashcardSchema] 24 | 25 | @classmethod 26 | def build(cls, flashcards: List[Flashcard]) -> dict: 27 | return cls( 28 | flashcards = [ 29 | FlashcardSchema(id=flashcard.id, question=flashcard.question, answer=flashcard.answer, category_id=flashcard.category.id, slug=flashcard.slug) 30 | for flashcard in flashcards 31 | ] 32 | ).model_dump() 33 | -------------------------------------------------------------------------------- /packages/api/src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shared.infra.django.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /packages/api/src/shared/domain/entity.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, TypeVar 3 | 4 | 5 | @dataclass(kw_only=True) 6 | class Entity: 7 | id: int | None = None 8 | 9 | def __eq__(self, other: Any) -> bool: 10 | if isinstance(other, type(self)): 11 | return self.id == other.id 12 | return False 13 | 14 | def __hash__(self): 15 | return hash(self.id) 16 | 17 | EntityType = TypeVar("EntityType", bound=Entity) -------------------------------------------------------------------------------- /packages/api/src/shared/domain/exception.py: -------------------------------------------------------------------------------- 1 | class BaseMsgException(Exception): 2 | message: str 3 | 4 | def __str__(self): 5 | return self.message 6 | 7 | class ModelExistsError(BaseMsgException): 8 | message = "Model already exists" 9 | -------------------------------------------------------------------------------- /packages/api/src/shared/infra/django/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for api project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shared.infra.django.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /packages/api/src/shared/infra/django/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for api project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.1.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.1/ref/settings/ 11 | """ 12 | 13 | from pathlib import Path 14 | 15 | import environ 16 | 17 | env = environ.Env() 18 | env.read_env(".env") 19 | 20 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 21 | BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent 22 | 23 | 24 | # Quick-start development settings - unsuitable for production 25 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ 26 | 27 | # SECURITY WARNING: keep the secret key used in production secret! 28 | SECRET_KEY = ( 29 | "django-insecure-du4_&65!s7+k62o0y&ou3wjkk&j(-9mk*s0sz&4f3)d-rlpl7+" 30 | ) 31 | 32 | # SECURITY WARNING: don't run with debug turned on in production! 33 | DEBUG = False 34 | 35 | ALLOWED_HOSTS = ["localhost", "127.0.0.1", "api"] 36 | 37 | 38 | # Application definition 39 | 40 | INSTALLED_APPS = [ 41 | # "django.contrib.admin", 42 | # "django.contrib.auth", 43 | # "django.contrib.contenttypes", 44 | # "django.contrib.sessions", 45 | # "django.contrib.messages", 46 | # "django.contrib.staticfiles", 47 | "flashcard.infra.django.apps.FlashcardConfig", 48 | "category.infra.django.apps.CategoryConfig", 49 | ] 50 | 51 | MIGRATION_MODULES = { 52 | "flashcard": "flashcard.infra.database.migrations", 53 | "category": "category.infra.database.migrations", 54 | } 55 | 56 | MIDDLEWARE = [ 57 | "django.middleware.security.SecurityMiddleware", 58 | # "django.contrib.sessions.middleware.SessionMiddleware", 59 | "django.middleware.common.CommonMiddleware", 60 | # "django.middleware.csrf.CsrfViewMiddleware", 61 | # "django.contrib.auth.middleware.AuthenticationMiddleware", 62 | # "django.contrib.messages.middleware.MessageMiddleware", 63 | # "django.middleware.clickjacking.XFrameOptionsMiddleware", 64 | ] 65 | 66 | ROOT_URLCONF = "shared.presentation.rest.api" 67 | 68 | TEMPLATES = [ 69 | { 70 | "BACKEND": "django.template.backends.django.DjangoTemplates", 71 | "DIRS": [], 72 | "APP_DIRS": True, 73 | "OPTIONS": { 74 | "context_processors": [ 75 | "django.template.context_processors.debug", 76 | "django.template.context_processors.request", 77 | "django.contrib.auth.context_processors.auth", 78 | "django.contrib.messages.context_processors.messages", 79 | ], 80 | }, 81 | }, 82 | ] 83 | 84 | WSGI_APPLICATION = "shared.infra.django.wsgi.application" 85 | 86 | # Database 87 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases 88 | 89 | DATABASES = { 90 | "default": { 91 | "ENGINE": "django.db.backends.postgresql", 92 | "NAME": env("DB_NAME"), 93 | "USER": env("DB_USER"), 94 | "PASSWORD": env("DB_PASSWORD"), 95 | "HOST": env("DB_HOST"), 96 | "PORT": env("DB_PORT"), 97 | } 98 | } 99 | 100 | 101 | # Password validation 102 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators 103 | 104 | AUTH_PASSWORD_VALIDATORS = [ 105 | { 106 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 107 | }, 108 | { 109 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 110 | }, 111 | { 112 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 113 | }, 114 | { 115 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 116 | }, 117 | ] 118 | 119 | 120 | # Internationalization 121 | # https://docs.djangoproject.com/en/5.1/topics/i18n/ 122 | 123 | LANGUAGE_CODE = "en-us" 124 | 125 | TIME_ZONE = "America/Toronto" 126 | 127 | USE_I18N = True 128 | 129 | USE_TZ = True 130 | 131 | 132 | # Static files (CSS, JavaScript, Images) 133 | # https://docs.djangoproject.com/en/5.1/howto/static-files/ 134 | 135 | STATIC_URL = "static/" 136 | 137 | # Default primary key field type 138 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field 139 | 140 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 141 | -------------------------------------------------------------------------------- /packages/api/src/shared/infra/django/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for api project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shared.infra.django.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /packages/api/src/shared/infra/repository/cursor_wrapper.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.db.backends.utils import CursorWrapper 4 | 5 | 6 | def delayed_execute(func): 7 | @wraps(func) 8 | def wrapper(self, sql, params=None): 9 | sql = f"SELECT pg_sleep(0.05); {sql}" 10 | 11 | return func(self, sql, params) 12 | 13 | return wrapper 14 | 15 | 16 | CursorWrapper.execute = delayed_execute(CursorWrapper.execute) 17 | -------------------------------------------------------------------------------- /packages/api/src/shared/infra/repository/mapper.py: -------------------------------------------------------------------------------- 1 | from typing import List, TypeVar 2 | 3 | from django.db.models import Model 4 | 5 | from shared.domain.entity import EntityType 6 | 7 | DjangoModelType = TypeVar("EntityType", bound=Model) 8 | 9 | 10 | class ModelMapperInterface: 11 | def to_entity(self, model: DjangoModelType) -> EntityType: 12 | raise NotImplementedError 13 | 14 | def to_instance(self, entity: EntityType) -> DjangoModelType: 15 | raise NotImplementedError 16 | 17 | def to_entity_list(self, instances: List[DjangoModelType]) -> List[EntityType]: 18 | return [self.to_entity(instance) for instance in instances] 19 | 20 | def to_instance_list(self, entities: List[EntityType]) -> List[DjangoModelType]: 21 | return [self.to_instance(entity) for entity in entities] 22 | -------------------------------------------------------------------------------- /packages/api/src/shared/infra/repository/rdb.py: -------------------------------------------------------------------------------- 1 | from django.db import IntegrityError 2 | 3 | from shared.domain.entity import EntityType 4 | from shared.domain.exception import ModelExistsError 5 | from shared.infra.repository.mapper import DjangoModelType, ModelMapperInterface 6 | 7 | 8 | class RDBRepository: 9 | model_mapper: ModelMapperInterface 10 | 11 | def save(self, entity: EntityType) -> EntityType: 12 | instance: DjangoModelType = self.model_mapper.to_instance(entity=entity) 13 | try: 14 | instance.save() 15 | except IntegrityError as e: 16 | raise ModelExistsError(e) 17 | 18 | return self.model_mapper.to_entity(instance=instance) -------------------------------------------------------------------------------- /packages/api/src/shared/presentation/rest/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | from functools import wraps 5 | 6 | # from django.contrib import admin 7 | from django.urls import path 8 | from groq import Groq 9 | from ninja import NinjaAPI 10 | 11 | from category.presentation.rest.api import router as category_router 12 | from category.presentation.rest.containers import category_command 13 | from flashcard.presentation.rest.api import router as flashcard_router 14 | from flashcard.presentation.rest.containers import flashcard_command 15 | from shared.domain.exception import ModelExistsError 16 | 17 | api = NinjaAPI( 18 | title="Flashcards API", 19 | description="A demo API for Lazar's flashcards app", 20 | ) 21 | 22 | api.add_router("categories", category_router) 23 | api.add_router("flashcards", flashcard_router) 24 | 25 | 26 | @api.get("/wipe-database") 27 | def wipe_database(request): 28 | from django.db import connection 29 | 30 | with connection.cursor() as cursor: 31 | cursor.execute( 32 | "TRUNCATE TABLE flashcard_flashcard, category_category CASCADE;" 33 | ) 34 | return {"message": "Database wiped successfully"} 35 | 36 | 37 | def retry_on_error(max_attempts=3, delay_seconds=1): 38 | def decorator(func): 39 | @wraps(func) 40 | def wrapper(*args, **kwargs): 41 | attempts = 0 42 | while attempts < max_attempts: 43 | try: 44 | return func(*args, **kwargs) 45 | except (json.JSONDecodeError, ValueError) as e: 46 | attempts += 1 47 | if attempts == max_attempts: 48 | raise e 49 | time.sleep(delay_seconds) 50 | return func(*args, **kwargs) 51 | 52 | return wrapper 53 | 54 | return decorator 55 | 56 | 57 | @api.get("/populate-database") 58 | def populate_database(request): 59 | try: 60 | categories = generate_dummy_data() 61 | 62 | for category in categories: 63 | category_obj = category_command.create_category( 64 | name=category["name"] 65 | ) 66 | 67 | for flashcard in category["flashcards"]: 68 | try: 69 | flashcard_command.create_flashcard( 70 | question=flashcard["question"], 71 | answer=flashcard["answer"], 72 | category=category_obj, 73 | ) 74 | except ModelExistsError as e: 75 | print( 76 | f"Error creating flashcard {flashcard['question']}: {str(e)}" 77 | ) 78 | 79 | print("Database populated successfully!!!!!") 80 | except Exception as e: 81 | raise ValueError(f"Unexpected error: {str(e)}") 82 | 83 | 84 | @retry_on_error(max_attempts=5) 85 | def generate_dummy_data(): 86 | categories = [] 87 | 88 | groq = Groq(api_key=os.getenv("GROQ_API_KEY")) 89 | 90 | # Add more explicit JSON formatting instructions 91 | messages = [ 92 | { 93 | "role": "system", 94 | "content": """You are a JSON generator. Only output valid, complete JSON arrays without any additional text or formatting.""", 95 | }, 96 | { 97 | "role": "user", 98 | "content": "Generate flashcards for a superstore that has different departments and weird products that don't exist and don't go in those departments. Something whimsical. There are 'categories' which are the different superstores. The flashcard's question is a product that doesn't exist like 'Wooden towel' of 'Smelling stones', and the answer is a department that can't sell that product like 'Dairy' or 'Bakery' - you can't expect to find a wooden towel in the dairy department. Generate 1 category (superstore) with a random number of flashcards between 30 and 50. Generate everything in JSON format, and don't include anything other than JSON in your response. The response should be a valid and complete JSON array of objects that contain a name property which is the name of the superstore, and a flashcards array which contains the flashcards for that superstore. All values should be unique and not repeated.", 99 | }, 100 | ] 101 | 102 | try: 103 | response = groq.chat.completions.create( 104 | model="llama3-8b-8192", 105 | messages=messages, 106 | ) 107 | 108 | json_string = response.choices[0].message.content 109 | # Clean the string 110 | json_string = json_string.strip() 111 | json_string = json_string.replace("'", '"') 112 | json_string = json_string.replace("\n", "") 113 | json_string = json_string.replace("\t", "") 114 | json_string = json_string.replace("\\n", "") 115 | json_string = json_string.replace("\\", "") 116 | 117 | # Validate JSON structure 118 | if not (json_string.startswith("[") and json_string.endswith("]")): 119 | raise ValueError("Invalid JSON array structure") 120 | 121 | try: 122 | json_data = json.loads(json_string) 123 | except json.JSONDecodeError as e: 124 | raise ValueError(f"JSON parsing failed: {str(e)}") 125 | 126 | # Validate data structure 127 | if not isinstance(json_data, list): 128 | raise ValueError("Root element must be an array") 129 | 130 | for category in json_data: 131 | if not isinstance(category, dict): 132 | raise ValueError("Each category must be an object") 133 | if "name" not in category or "flashcards" not in category: 134 | raise ValueError("Category missing required fields") 135 | if not isinstance(category["flashcards"], list): 136 | raise ValueError("Flashcards must be an array") 137 | 138 | for flashcard in category["flashcards"]: 139 | if not isinstance(flashcard, dict): 140 | raise ValueError("Each flashcard must be an object") 141 | if "question" not in flashcard or "answer" not in flashcard: 142 | raise ValueError("Flashcard missing required fields") 143 | 144 | categories.append(category) 145 | 146 | return categories 147 | 148 | except Exception as e: 149 | raise ValueError(f"Unexpected error: {str(e)}") 150 | 151 | 152 | urlpatterns = [ 153 | # path("admin/", admin.site.urls), 154 | path("", api.urls), 155 | ] 156 | -------------------------------------------------------------------------------- /packages/api/src/shared/presentation/rest/response.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | 3 | from ninja import Schema 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def response(results: dict | list) -> dict: 9 | return {"results": results} 10 | 11 | 12 | class ObjectResponse(Schema, Generic[T]): 13 | results: T 14 | 15 | 16 | def error_response(msg: str) -> dict: 17 | return {"results": {"message": msg}} 18 | 19 | 20 | class ErrorMessageResponse(Schema): 21 | message: str -------------------------------------------------------------------------------- /packages/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /packages/web/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["vsls-contrib.codetour"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/web/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js: debug full stack", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npm run dev", 9 | "serverReadyAction": { 10 | "pattern": "started server on .+, url: (https?://.+)", 11 | "uriFormat": "%s", 12 | "action": "debugWithChrome" 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /packages/web/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } 5 | -------------------------------------------------------------------------------- /packages/web/Dockerfile-dev: -------------------------------------------------------------------------------- 1 | # Use an official Node.js image as the base image 2 | FROM node:22-alpine 3 | 4 | # Set the working directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy only package.json and package-lock.json (or yarn.lock) to leverage Docker cache 8 | COPY package.json package-lock.json ./ 9 | 10 | # Install dependencies 11 | RUN npm ci 12 | 13 | # Copy the rest of your application code 14 | COPY . . 15 | 16 | # Expose the Next.js development server port 17 | EXPOSE 3000 18 | 19 | # Set environment variables 20 | ENV NODE_ENV development 21 | ENV PORT 3000 22 | 23 | # Start the Next.js application in development mode 24 | CMD ["npm", "run", "dev"] 25 | -------------------------------------------------------------------------------- /packages/web/Dockerfile-prod: -------------------------------------------------------------------------------- 1 | # syntax=docker.io/docker/dockerfile:1 2 | 3 | FROM node:22-alpine AS base 4 | 5 | # Install dependencies only when needed 6 | FROM base AS deps 7 | # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. 8 | RUN apk add --no-cache libc6-compat 9 | WORKDIR /app 10 | 11 | # Install dependencies based on the preferred package manager 12 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ 13 | RUN \ 14 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 15 | elif [ -f package-lock.json ]; then npm ci; \ 16 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 17 | else echo "Lockfile not found." && exit 1; \ 18 | fi 19 | 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 | # Production image, copy all the files and run next 40 | FROM base AS runner 41 | WORKDIR /app 42 | 43 | ENV NODE_ENV=production 44 | # Uncomment the following line in case you want to disable telemetry during runtime. 45 | # ENV NEXT_TELEMETRY_DISABLED=1 46 | 47 | RUN addgroup --system --gid 1001 nodejs 48 | RUN adduser --system --uid 1001 nextjs 49 | 50 | COPY --from=builder /app/public ./public 51 | 52 | # Automatically leverage output traces to reduce image size 53 | # https://nextjs.org/docs/advanced-features/output-file-tracing 54 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 55 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 56 | 57 | USER nextjs 58 | 59 | EXPOSE 3000 60 | 61 | ENV PORT=3000 62 | 63 | # server.js is created by next build from the standalone output 64 | # https://nextjs.org/docs/pages/api-reference/next-config-js/output 65 | ENV HOSTNAME="0.0.0.0" 66 | CMD ["node", "server.js"] 67 | -------------------------------------------------------------------------------- /packages/web/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": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/web/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | output: "standalone", 4 | async redirects() { 5 | return [ 6 | { 7 | source: '/', 8 | destination: '/manage', 9 | permanent: true, 10 | }, 11 | ]; 12 | }, 13 | }; 14 | 15 | module.exports = nextConfig; 16 | -------------------------------------------------------------------------------- /packages/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flashcards", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "seed": "prisma migrate reset" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-accordion": "^1.1.2", 14 | "@radix-ui/react-alert-dialog": "^1.0.5", 15 | "@radix-ui/react-dialog": "^1.0.5", 16 | "@radix-ui/react-dropdown-menu": "^2.0.6", 17 | "@radix-ui/react-label": "^2.0.2", 18 | "@radix-ui/react-popover": "^1.0.7", 19 | "@radix-ui/react-select": "^2.0.0", 20 | "@radix-ui/react-slot": "^1.0.2", 21 | "@radix-ui/react-toggle": "^1.0.3", 22 | "@radix-ui/react-tooltip": "^1.0.7", 23 | "@tanstack/react-query": "^5.45.0", 24 | "@tanstack/react-table": "^8.17.3", 25 | "class-variance-authority": "^0.7.0", 26 | "clsx": "^2.1.1", 27 | "cmdk": "^1.0.0", 28 | "color": "^4.2.3", 29 | "lucide-react": "^0.394.0", 30 | "next": "14.2.3", 31 | "next-themes": "^0.3.0", 32 | "react": "18.3.1", 33 | "react-dom": "18.3.1", 34 | "sonner": "^1.5.0", 35 | "tailwind-merge": "^2.3.0", 36 | "tailwindcss-animate": "^1.0.7", 37 | "zod": "^3.23.8" 38 | }, 39 | "devDependencies": { 40 | "@tanstack/eslint-plugin-query": "^5.43.1", 41 | "@types/color": "^3.0.6", 42 | "@types/node": "20.14.2", 43 | "@types/react": "18.3.3", 44 | "@types/react-dom": "18.3.0", 45 | "eslint": "^8", 46 | "eslint-config-next": "14.2.3", 47 | "postcss": "^8.4.38", 48 | "tailwindcss": "^3.4.4", 49 | "ts-node": "^10.9.2", 50 | "typescript": "^5" 51 | } 52 | } -------------------------------------------------------------------------------- /packages/web/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /packages/web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikolovlazar/flashcards/b87fe5300f142c0779d0960a9254dc42cd85ad79/packages/web/public/favicon.ico -------------------------------------------------------------------------------- /packages/web/src/app/api/categories/[id]/flashcards/route.ts: -------------------------------------------------------------------------------- 1 | // Get flashcards for category 2 | export async function GET(_: Request, { params }: { params: { id: string } }) { 3 | const categoryId = parseInt(params.id, 10); 4 | 5 | const categoryRes = await fetch(`http://api:8000/categories/${categoryId}`, { 6 | cache: 'no-store', 7 | }); 8 | 9 | if (!categoryRes.ok) { 10 | const error = await categoryRes.text(); 11 | return new Response(error, { status: categoryRes.status }); 12 | } 13 | const category = (await categoryRes.json()).results.category; 14 | 15 | const flashcardsRes = await fetch( 16 | `http://api:8000/categories/${categoryId}/flashcards`, 17 | { cache: 'no-store' } 18 | ); 19 | 20 | if (!flashcardsRes.ok) { 21 | const error = await flashcardsRes.text(); 22 | return new Response(error, { status: flashcardsRes.status }); 23 | } 24 | const flashcards = (await flashcardsRes.json()).results.flashcards; 25 | 26 | return Response.json({ 27 | category, 28 | flashcards, 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /packages/web/src/app/api/categories/[id]/route.ts: -------------------------------------------------------------------------------- 1 | // Update category 2 | export async function PATCH( 3 | request: Request, 4 | { params }: { params: { id: string } } 5 | ) { 6 | const id = parseInt(params.id, 10); 7 | const formData = await request.formData(); 8 | 9 | const res = await fetch(`http://api:8000/categories/${id}`, { 10 | method: 'PATCH', 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | }, 14 | body: JSON.stringify(Object.fromEntries(formData)), 15 | }); 16 | 17 | if (!res.ok) { 18 | const error = await res.text(); 19 | return new Response(error, { status: res.status }); 20 | } 21 | 22 | const category = (await res.json()).results.category; 23 | 24 | return Response.json({ category }, { status: 200 }); 25 | } 26 | 27 | // Delete category 28 | export async function DELETE( 29 | _: Request, 30 | { params }: { params: { id: string } } 31 | ) { 32 | const id = parseInt(params.id, 10); 33 | 34 | const res = await fetch(`http://api:8000/categories/${id}`, { 35 | method: 'DELETE', 36 | }); 37 | 38 | if (!res.ok) { 39 | const error = await res.text(); 40 | return new Response(error, { status: res.status }); 41 | } 42 | 43 | return Response.json({ success: true }, { status: 200 }); 44 | } 45 | -------------------------------------------------------------------------------- /packages/web/src/app/api/categories/route.ts: -------------------------------------------------------------------------------- 1 | // Create Category 2 | export async function POST(request: Request) { 3 | const formData = await request.formData(); 4 | 5 | const res = await fetch('http://api:8000/categories', { 6 | body: JSON.stringify(Object.fromEntries(formData)), 7 | method: 'POST', 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | }, 11 | }); 12 | 13 | if (!res.ok) { 14 | const error = await res.text(); 15 | return new Response(error, { status: res.status }); 16 | } 17 | 18 | const category = (await res.json()).results.category; 19 | return Response.json(category, { status: 201 }); 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/app/api/flashcards/[id]/route.ts: -------------------------------------------------------------------------------- 1 | // Update flashcard 2 | export async function PATCH( 3 | request: Request, 4 | { params }: { params: { id: string } } 5 | ) { 6 | const formData = await request.formData(); 7 | const id = parseInt(params.id, 10); 8 | 9 | const res = await fetch(`http://api:8000/flashcards/${id}`, { 10 | method: 'PATCH', 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | }, 14 | body: JSON.stringify(Object.fromEntries(formData)), 15 | }); 16 | 17 | if (!res.ok) { 18 | const error = await res.text(); 19 | return new Response(error, { status: res.status }); 20 | } 21 | 22 | const updatedFlashcard = (await res.json()).results.flashcard; 23 | 24 | return Response.json({ flashcard: updatedFlashcard }, { status: 200 }); 25 | } 26 | 27 | // Delete flashcard 28 | export async function DELETE( 29 | _: Request, 30 | { params }: { params: { id: string } } 31 | ) { 32 | const id = parseInt(params.id, 10); 33 | 34 | const res = await fetch(`http://api:8000/flashcards/${id}`, { 35 | method: 'DELETE', 36 | }); 37 | 38 | if (!res.ok) { 39 | const error = await res.text(); 40 | return new Response(error, { status: res.status }); 41 | } 42 | 43 | return new Response('Flashcard deleted successfully!', { status: 200 }); 44 | } 45 | -------------------------------------------------------------------------------- /packages/web/src/app/api/flashcards/route.ts: -------------------------------------------------------------------------------- 1 | export async function POST(request: Request) { 2 | const formData = await request.formData(); 3 | 4 | const res = await fetch('http://api:8000/flashcards', { 5 | headers: { 6 | 'Content-Type': 'application/json', 7 | }, 8 | body: JSON.stringify(Object.fromEntries(formData)), 9 | method: 'POST', 10 | }); 11 | 12 | if (!res.ok) { 13 | const error = await res.text(); 14 | return new Response(error, { status: res.status }); 15 | } 16 | 17 | const flashcard = (await res.json()).results.flashcard; 18 | 19 | return Response.json({ flashcard }, { status: 201 }); 20 | } 21 | -------------------------------------------------------------------------------- /packages/web/src/app/api/populate-database/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | export async function GET() { 4 | const response = await fetch('http://api:8000/populate-database', { 5 | cache: 'no-store', 6 | }); 7 | return NextResponse.json(response.json()); 8 | } 9 | -------------------------------------------------------------------------------- /packages/web/src/app/api/wipe-database/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | 3 | export async function GET() { 4 | const response = await fetch('http://api:8000/wipe-database', { 5 | cache: 'no-store', 6 | }); 7 | return NextResponse.json(response.json()); 8 | } 9 | -------------------------------------------------------------------------------- /packages/web/src/app/database-operations.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/ui/button'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useState } from 'react'; 6 | 7 | export default function DatabaseOperations() { 8 | const [isWiping, setIsWiping] = useState(false); 9 | const [isPopulating, setIsPopulating] = useState(false); 10 | const router = useRouter(); 11 | 12 | const wipeDatabase = async () => { 13 | setIsWiping(true); 14 | await fetch('/api/wipe-database'); 15 | setIsWiping(false); 16 | router.refresh(); 17 | }; 18 | 19 | const populateDatabase = async () => { 20 | setIsPopulating(true); 21 | await fetch('/api/populate-database'); 22 | setIsPopulating(false); 23 | router.refresh(); 24 | }; 25 | 26 | return ( 27 | <> 28 | 31 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /packages/web/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 0 0% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 0 0% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 0 0% 3.9%; 13 | --primary: 0 0% 9%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 0 0% 96.1%; 16 | --secondary-foreground: 0 0% 9%; 17 | --muted: 0 0% 96.1%; 18 | --muted-foreground: 0 0% 45.1%; 19 | --accent: 0 0% 96.1%; 20 | --accent-foreground: 0 0% 9%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 0 0% 89.8%; 24 | --input: 0 0% 89.8%; 25 | --ring: 0 0% 3.9%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 0 0% 3.9%; 31 | --foreground: 0 0% 98%; 32 | --card: 0 0% 3.9%; 33 | --card-foreground: 0 0% 98%; 34 | --popover: 0 0% 3.9%; 35 | --popover-foreground: 0 0% 98%; 36 | --primary: 0 0% 98%; 37 | --primary-foreground: 0 0% 9%; 38 | --secondary: 0 0% 14.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 0 0% 14.9%; 41 | --muted-foreground: 0 0% 63.9%; 42 | --accent: 0 0% 14.9%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 0% 98%; 46 | --border: 0 0% 14.9%; 47 | --input: 0 0% 14.9%; 48 | --ring: 0 0% 83.1%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply bg-background text-foreground; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/web/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import { Inter as FontSans } from 'next/font/google'; 3 | import './globals.css'; 4 | import { cn } from '@/lib/utils'; 5 | import Link from 'next/link'; 6 | import { Brain, Menu } from 'lucide-react'; 7 | import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; 8 | import { Button } from '@/components/ui/button'; 9 | import { Toaster } from '@/components/ui/sonner'; 10 | import Providers from './providers'; 11 | import DatabaseOperations from './database-operations'; 12 | 13 | const fontSans = FontSans({ 14 | subsets: ['latin'], 15 | variable: '--font-sans', 16 | }); 17 | 18 | export const metadata: Metadata = { 19 | title: 'Flashcards 🧠', 20 | description: 'A simple flashcard app to help you study.', 21 | }; 22 | 23 | export default function RootLayout({ 24 | children, 25 | }: Readonly<{ 26 | children: React.ReactNode; 27 | }>) { 28 | return ( 29 | 30 | 36 | 37 |
38 |
39 | 65 | 66 | 67 | 75 | 76 | 77 | 99 | 100 | 101 |
102 |
103 | {children} 104 |
105 |
106 | 107 |
108 | 109 | 110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /packages/web/src/app/manage/_categories/categories-columns.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ColumnDef } from '@tanstack/react-table'; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from '@/components/ui/dropdown-menu'; 10 | import { Button } from '@/components/ui/button'; 11 | import { MoreHorizontal } from 'lucide-react'; 12 | import EditCategory from './edit-category-dialog'; 13 | import ConfirmDelete from './confirm-category-delete'; 14 | import { Category } from '@/lib/models'; 15 | 16 | export const categoriesColumns: ColumnDef[] = [ 17 | { 18 | accessorKey: 'id', 19 | header: 'ID', 20 | }, 21 | { 22 | accessorKey: 'name', 23 | header: 'Name', 24 | }, 25 | { 26 | accessorKey: 'slug', 27 | header: 'Slug', 28 | }, 29 | { 30 | id: 'actions', 31 | cell: ({ row }) => { 32 | const category = row.original; 33 | 34 | return ( 35 | 36 | 37 | 41 | 42 | 43 | 44 | e.preventDefault()} 47 | > 48 | Edit 49 | 50 | 51 | 52 | e.preventDefault()} 55 | > 56 | Delete 57 | 58 | 59 | 60 | 61 | ); 62 | }, 63 | }, 64 | ]; 65 | -------------------------------------------------------------------------------- /packages/web/src/app/manage/_categories/categories-data-table.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | flexRender, 5 | getCoreRowModel, 6 | useReactTable, 7 | } from "@tanstack/react-table"; 8 | 9 | import { 10 | Table, 11 | TableBody, 12 | TableCell, 13 | TableHead, 14 | TableHeader, 15 | TableRow, 16 | } from "@/components/ui/table"; 17 | import { type Category } from "@/lib/models"; 18 | import { categoriesColumns } from "./categories-columns"; 19 | 20 | export function CategoriesDataTable({ 21 | categories, 22 | }: { 23 | categories: Category[]; 24 | }) { 25 | const table = useReactTable({ 26 | data: categories, 27 | columns: categoriesColumns, 28 | getCoreRowModel: getCoreRowModel(), 29 | }); 30 | 31 | return ( 32 |
33 | 34 | 35 | {table.getHeaderGroups().map((headerGroup) => ( 36 | 37 | {headerGroup.headers.map((header) => { 38 | return ( 39 | 40 | {header.isPlaceholder 41 | ? null 42 | : flexRender( 43 | header.column.columnDef.header, 44 | header.getContext(), 45 | )} 46 | 47 | ); 48 | })} 49 | 50 | ))} 51 | 52 | 53 | {table.getRowModel().rows?.length ? ( 54 | table.getRowModel().rows.map((row) => ( 55 | 59 | {row.getVisibleCells().map((cell) => ( 60 | 61 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 62 | 63 | ))} 64 | 65 | )) 66 | ) : ( 67 | 68 | 72 | No results. 73 | 74 | 75 | )} 76 | 77 |
78 |
79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /packages/web/src/app/manage/_categories/confirm-category-delete.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogCancel, 6 | AlertDialogContent, 7 | AlertDialogDescription, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogTitle, 11 | AlertDialogTrigger, 12 | } from '@/components/ui/alert-dialog'; 13 | import { Button } from '@/components/ui/button'; 14 | import { HiddenInput } from '@/components/ui/hidden-input'; 15 | import type { Category } from '@/lib/models'; 16 | import { Loader } from 'lucide-react'; 17 | import { useRouter } from 'next/navigation'; 18 | import { useState, type ReactNode } from 'react'; 19 | import { toast } from 'sonner'; 20 | 21 | export default function ConfirmDelete({ 22 | category, 23 | children, 24 | }: { 25 | category: Category; 26 | children: ReactNode; 27 | }) { 28 | const [open, setOpen] = useState(false); 29 | const [isDeleting, setIsDeleting] = useState(false); 30 | const router = useRouter(); 31 | 32 | const handleDelete = async () => { 33 | setIsDeleting(true); 34 | 35 | const res = await fetch(`/api/categories/${category.id}`, { 36 | method: 'DELETE', 37 | }); 38 | 39 | if (res.ok) { 40 | toast.success('Category deleted'); 41 | setOpen(false); 42 | router.refresh(); 43 | } else { 44 | const message = await res.text(); 45 | toast.error(message); 46 | } 47 | 48 | setIsDeleting(false); 49 | }; 50 | 51 | return ( 52 | 53 | {children} 54 | 55 | 56 | 57 | Are you sure you want to delete the "{category.name}" 58 | category? 59 | 60 | 61 | This action cannot be undone. This will permanently delete the 62 | category, and all its flashcards. 63 | 64 | 65 | 66 | 67 | Cancel 68 | 75 | 76 | 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /packages/web/src/app/manage/_categories/create-category-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Plus } from 'lucide-react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useState } from 'react'; 6 | import { toast } from 'sonner'; 7 | 8 | import { Button } from '@/components/ui/button'; 9 | import { 10 | Dialog, 11 | DialogContent, 12 | DialogFooter, 13 | DialogHeader, 14 | DialogTitle, 15 | DialogTrigger, 16 | } from '@/components/ui/dialog'; 17 | import { Input } from '@/components/ui/input'; 18 | import { Label } from '@/components/ui/label'; 19 | 20 | export default function CreateCategory() { 21 | const [open, setOpen] = useState(false); 22 | const router = useRouter(); 23 | 24 | const handleSubmit = async (event: React.FormEvent) => { 25 | event.preventDefault(); 26 | 27 | const formData = new FormData(event.currentTarget); 28 | await fetch('/api/categories', { 29 | method: 'POST', 30 | body: formData, 31 | }); 32 | 33 | toast.success('Category created'); 34 | setOpen(false); 35 | router.refresh(); 36 | }; 37 | 38 | return ( 39 | 40 | 41 | 44 | 45 | 46 | 47 | Create a new category 48 | 49 |
50 |
51 | 52 | 53 |
54 |
55 | 56 | 59 | 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /packages/web/src/app/manage/_categories/edit-category-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/ui/button'; 4 | import { 5 | Dialog, 6 | DialogContent, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | DialogTrigger, 11 | } from '@/components/ui/dialog'; 12 | import { Input } from '@/components/ui/input'; 13 | import { Label } from '@/components/ui/label'; 14 | import { Category } from '@/lib/models'; 15 | import { useRouter } from 'next/navigation'; 16 | import { useState, type ReactNode } from 'react'; 17 | import { toast } from 'sonner'; 18 | 19 | export default function EditCategory({ 20 | category, 21 | children, 22 | }: { 23 | category: Category; 24 | children: ReactNode; 25 | }) { 26 | const [open, setOpen] = useState(false); 27 | const router = useRouter(); 28 | 29 | const handleSubmit = async (event: React.FormEvent) => { 30 | event.preventDefault(); 31 | const formData = new FormData(event.currentTarget); 32 | const res = await fetch(`/api/categories/${category.id}`, { 33 | method: 'PATCH', 34 | body: formData, 35 | }); 36 | 37 | if (res.ok) { 38 | toast.success('Category updated'); 39 | setOpen(false); 40 | router.refresh(); 41 | } else { 42 | const message = await res.text(); 43 | toast.error(message); 44 | } 45 | }; 46 | 47 | return ( 48 | 49 | {children} 50 | 51 | 52 | Edit {category.name} 53 | 54 |
55 |
56 | 57 | 63 |
64 |
65 | 66 | 69 | 70 |
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /packages/web/src/app/manage/_flashcards/confirm-flashcard-delete.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogCancel, 6 | AlertDialogContent, 7 | AlertDialogDescription, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogTitle, 11 | AlertDialogTrigger, 12 | } from '@/components/ui/alert-dialog'; 13 | import { useState, type ReactNode } from 'react'; 14 | import { FlashcardColumn } from './flashcards-columns'; 15 | import { toast } from 'sonner'; 16 | import { useRouter } from 'next/navigation'; 17 | import { Button } from '@/components/ui/button'; 18 | import { Loader } from 'lucide-react'; 19 | 20 | export default function ConfirmDelete({ 21 | flashcard, 22 | children, 23 | }: { 24 | flashcard: FlashcardColumn; 25 | children: ReactNode; 26 | }) { 27 | const [open, setOpen] = useState(false); 28 | const [isDeleting, setIsDeleting] = useState(false); 29 | const router = useRouter(); 30 | 31 | const handleDelete = async () => { 32 | setIsDeleting(true); 33 | 34 | const res = await fetch(`/api/flashcards/${flashcard.id}`, { 35 | method: 'DELETE', 36 | }); 37 | 38 | if (res.ok) { 39 | toast.success('Flashcard deleted'); 40 | setOpen(false); 41 | router.refresh(); 42 | } else { 43 | const message = await res.text(); 44 | toast.error(message); 45 | } 46 | 47 | setIsDeleting(false); 48 | }; 49 | 50 | return ( 51 | 52 | {children} 53 | 54 | 55 | 56 | Are you sure you want to delete the "{flashcard.question}" 57 | flashcard? 58 | 59 | 60 | This action cannot be undone. This will permanently delete the 61 | flashcard. 62 | 63 | 64 | 65 | Cancel 66 | 73 | 74 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /packages/web/src/app/manage/_flashcards/create-flashcard-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/components/ui/button'; 4 | import CategorySelect from './select-category'; 5 | import { 6 | Dialog, 7 | DialogContent, 8 | DialogFooter, 9 | DialogHeader, 10 | DialogTitle, 11 | DialogTrigger, 12 | } from '@/components/ui/dialog'; 13 | import { Input } from '@/components/ui/input'; 14 | import { Label } from '@/components/ui/label'; 15 | import { Textarea } from '@/components/ui/textarea'; 16 | import type { Category } from '@/lib/models'; 17 | import { Plus } from 'lucide-react'; 18 | import { useState } from 'react'; 19 | import { toast } from 'sonner'; 20 | import { useRouter } from 'next/navigation'; 21 | 22 | export default function CreateFlashcard({ 23 | categories, 24 | }: { 25 | categories: Category[]; 26 | }) { 27 | const [open, setOpen] = useState(false); 28 | const router = useRouter(); 29 | 30 | const handleSubmit = async (event: React.FormEvent) => { 31 | event.preventDefault(); 32 | 33 | const formData = new FormData(event.currentTarget); 34 | const res = await fetch('/api/flashcards', { 35 | method: 'POST', 36 | body: formData, 37 | }); 38 | 39 | if (res.ok) { 40 | toast.success('Flashcard created'); 41 | setOpen(false); 42 | router.refresh(); 43 | } else { 44 | const message = await res.text(); 45 | toast.error(message); 46 | } 47 | }; 48 | 49 | const [selectedCategory, setSelectedCategory] = useState( 50 | categories[0] 51 | ); 52 | return ( 53 | 54 | 55 | 58 | 59 | 60 | 61 | Create a new flashcard 62 | 63 |
68 |
69 | 70 | 76 |
77 |
78 | 79 |