diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index 1896a3e..0cc3f29 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -2,13 +2,12 @@ name: Django CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] jobs: build: - runs-on: ubuntu-latest services: @@ -24,20 +23,21 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.11 - uses: actions/setup-python@v2 - with: - python-version: 3.11 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run migrations - run: | - cd backend - python manage.py migrate - - name: Run tests - run: | - cd backend - python manage.py test + - uses: actions/checkout@v2 + - name: Set up Python 3.11 + uses: actions/setup-python@v2 + with: + python-version: 3.11 + - name: Install dependencies + run: | + cd backend + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run migrations + run: | + cd backend + python manage.py migrate + - name: Run tests + run: | + cd backend + python manage.py test diff --git a/.gitignore b/.gitignore index d6a7e2b..a40fc56 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,6 @@ cover/ # Django stuff: *.log -local_settings.py db.sqlite3 db.sqlite3-journal diff --git a/backend/authentications/urls.py b/backend/authentications/urls.py index 32965a4..6029eea 100644 --- a/backend/authentications/urls.py +++ b/backend/authentications/urls.py @@ -1,12 +1,11 @@ from django.urls import path from rest_framework_simplejwt import views as jwt_views -from authentications.views import ObtainTokenPairWithCustomView, GreetingView, GoogleLogin, GoogleRetrieveUserInfo +from authentications.views import ObtainTokenPairWithCustomView, GoogleRetrieveUserInfo, CheckAccessTokenAndRefreshToken urlpatterns = [ path('token/obtain/', jwt_views.TokenObtainPairView.as_view(), name='token_create'), path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'), path('token/custom_obtain/', ObtainTokenPairWithCustomView.as_view(), name='token_create_custom'), - path('hello/', GreetingView.as_view(), name='hello_world'), - path('dj-rest-auth/google/', GoogleLogin.as_view(), name="google_login"), path('auth/google/', GoogleRetrieveUserInfo.as_view()), + path('auth/status/', CheckAccessTokenAndRefreshToken.as_view(), name='check_token_status') ] \ No newline at end of file diff --git a/backend/authentications/views.py b/backend/authentications/views.py index 9b5f249..87f662e 100644 --- a/backend/authentications/views.py +++ b/backend/authentications/views.py @@ -1,6 +1,3 @@ -from django.shortcuts import render - -# Create your views here. """This module defines API views for authentication, user creation, and a simple hello message.""" import json @@ -10,14 +7,11 @@ from django.conf import settings from django.contrib.auth.hashers import make_password from rest_framework import status -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken - -from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter - -from dj_rest_auth.registration.views import SocialLoginView +from rest_framework_simplejwt.authentication import JWTAuthentication from google_auth_oauthlib.flow import InstalledAppFlow @@ -27,6 +21,31 @@ from users.managers import CustomAccountManager from users.models import CustomUser +class CheckAccessTokenAndRefreshToken(APIView): + permission_classes = (AllowAny,) + JWT_authenticator = JWTAuthentication() + + def post(self, request, *args, **kwargs): + access_token = request.data.get('access_token') + refresh_token = request.data.get('refresh_token') + # Check if the access token is valid + if access_token: + response = self.JWT_authenticator.authenticate(request) + if response is not None: + return Response({'status': 'true'}, status=status.HTTP_200_OK) + + # Check if the refresh token is valid + if refresh_token: + try: + refresh = RefreshToken(refresh_token) + access_token = str(refresh.access_token) + return Response({'access_token': access_token}, status=status.HTTP_200_OK) + except Exception as e: + return Response({'status': 'false'}, status=status.HTTP_401_UNAUTHORIZED) + + return Response({'status': 'false'}, status=status.HTTP_400_BAD_REQUEST) + + class ObtainTokenPairWithCustomView(APIView): """ Custom Token Obtain Pair View. @@ -45,39 +64,6 @@ class ObtainTokenPairWithCustomView(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class GreetingView(APIView): - """ - Hello World View. - Returns a greeting and user information for authenticated users. - """ - permission_classes = (IsAuthenticated,) - - def get(self, request): - """ - Retrieve a greeting message and user information. - """ - user = request.user - user_info = { - "username": user.username, - } - response_data = { - "message": "Hello, world!", - "user_info": user_info, - } - return Response(response_data, status=status.HTTP_200_OK) - - -class GoogleLogin(SocialLoginView): - """ - Google Login View. - Handles Google OAuth2 authentication. - """ - # permission_classes = (AllowAny,) - adapter_class = GoogleOAuth2Adapter - # client_class = OAuth2Client - # callback_url = 'http://localhost:8000/accounts/google/login/callback/' - - class GoogleRetrieveUserInfo(APIView): """ Retrieve user information from Google and create a user if not exists. @@ -165,4 +151,4 @@ class GoogleRetrieveUserInfo(APIView): response = requests.get(api_url, headers=headers) if response.status_code == 200: return response.json() - raise Exception('Google API Error', response) + raise Exception('Google API Error', response) \ No newline at end of file diff --git a/backend/boards/__init__.py b/backend/boards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/boards/admin.py b/backend/boards/admin.py new file mode 100644 index 0000000..41d21c7 --- /dev/null +++ b/backend/boards/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from .models import Board, ListBoard, KanbanTaskOrder + +@admin.register(Board) +class BoardAdmin(admin.ModelAdmin): + list_display = ['name', 'user'] + +@admin.register(ListBoard) +class ListBoardAdmin(admin.ModelAdmin): + list_display = ['name', 'position', 'board'] + list_filter = ['board', 'position'] + + +@admin.register(KanbanTaskOrder) +class KanbanTaskOrderAdmin(admin.ModelAdmin): + list_display = ['list_board', 'todo_order'] + list_filter = ['list_board'] \ No newline at end of file diff --git a/backend/boards/apps.py b/backend/boards/apps.py new file mode 100644 index 0000000..d10d8fa --- /dev/null +++ b/backend/boards/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class BoardsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'boards' + + def ready(self): + import boards.signals \ No newline at end of file diff --git a/backend/boards/migrations/0001_initial.py b/backend/boards/migrations/0001_initial.py new file mode 100644 index 0000000..2196132 --- /dev/null +++ b/backend/boards/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.6 on 2023-11-19 19:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Board', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ListBoard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('position', models.IntegerField()), + ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='boards.board')), + ], + ), + ] diff --git a/backend/boards/migrations/0002_kanbantaskorder.py b/backend/boards/migrations/0002_kanbantaskorder.py new file mode 100644 index 0000000..2926da5 --- /dev/null +++ b/backend/boards/migrations/0002_kanbantaskorder.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.6 on 2023-11-20 18:24 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('boards', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='KanbanTaskOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('todo_order', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(), blank=True, default=list, size=None)), + ('list_board', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='boards.listboard')), + ], + ), + ] diff --git a/backend/boards/migrations/__init__.py b/backend/boards/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/boards/models.py b/backend/boards/models.py new file mode 100644 index 0000000..06c4249 --- /dev/null +++ b/backend/boards/models.py @@ -0,0 +1,56 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models + +from users.models import CustomUser + +class Board(models.Model): + """ + Kanban board model. + + :param user: The user who owns the board. + :param name: The name of the board. + :param created_at: The date and time when the board was created. + """ + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + name = models.CharField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self) -> str: + return f"{self.name}" + + +class KanbanTaskOrder(models.Model): + """ + Model to store the order of Todo tasks in a Kanban board. + + :param list_board: The list board that the order belongs to. + :param todo_order: ArrayField to store the order of Todo IDs. + """ + list_board = models.OneToOneField('ListBoard', on_delete=models.CASCADE) + todo_order = ArrayField(models.PositiveIntegerField(), blank=True, default=list) + + def __str__(self): + return f"Order for {self.list_board}" + + +class ListBoard(models.Model): + """ + List inside a Kanban board. + + :param board: The board that the list belongs to. + :param name: The name of the list. + :param position: The position of the list in Kanban. + """ + board = models.ForeignKey(Board, on_delete=models.CASCADE) + name = models.CharField(max_length=255) + position = models.IntegerField() + + def save(self, *args, **kwargs): + super(ListBoard, self).save(*args, **kwargs) + kanban_order, created = KanbanTaskOrder.objects.get_or_create(list_board=self) + if not created: + return + + def __str__(self) -> str: + return f"{self.name}" + diff --git a/backend/boards/serializers.py b/backend/boards/serializers.py new file mode 100644 index 0000000..54c76ac --- /dev/null +++ b/backend/boards/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from boards.models import Board, ListBoard + +class BoardSerializer(serializers.ModelSerializer): + class Meta: + model = Board + fields = '__all__' + +class ListBoardSerializer(serializers.ModelSerializer): + class Meta: + model = ListBoard + fields = '__all__' diff --git a/backend/boards/signals.py b/backend/boards/signals.py new file mode 100644 index 0000000..2c44daa --- /dev/null +++ b/backend/boards/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from boards.models import Board, ListBoard +from users.models import CustomUser + +@receiver(post_save, sender=CustomUser) +def create_default_board(sender, instance, created, **kwargs): + """Signal handler to automatically create a default Board for a user upon creation.""" + if created: + # Create unique board by user id + user_id = instance.id + board = Board.objects.create(user=instance, name=f"Board of #{user_id}") + ListBoard.objects.create(board=board, name="Backlog", position=1) + ListBoard.objects.create(board=board, name="Doing", position=2) + ListBoard.objects.create(board=board, name="Review", position=3) + ListBoard.objects.create(board=board, name="Done", position=4) \ No newline at end of file diff --git a/backend/boards/tests.py b/backend/boards/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/boards/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/boards/urls.py b/backend/boards/urls.py new file mode 100644 index 0000000..b798e39 --- /dev/null +++ b/backend/boards/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from boards.views import BoardViewSet, ListBoardViewSet + +router = DefaultRouter() +router.register(r'boards', BoardViewSet, basename='board') +router.register(r'lists', ListBoardViewSet, basename='listboard') + +urlpatterns = [ + path('', include(router.urls)), +] \ No newline at end of file diff --git a/backend/boards/views.py b/backend/boards/views.py new file mode 100644 index 0000000..d405c87 --- /dev/null +++ b/backend/boards/views.py @@ -0,0 +1,34 @@ +from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import IsAuthenticated + +from boards.models import Board, ListBoard +from boards.serializers import BoardSerializer, ListBoardSerializer + +class BoardViewSet(viewsets.ModelViewSet): + permission_classes = (IsAuthenticated,) + queryset = Board.objects.all() + serializer_class = BoardSerializer + http_method_names = ['get'] + + def get_queryset(self): + queryset = Board.objects.filter(user_id=self.request.user.id) + return queryset + + +class ListBoardViewSet(viewsets.ModelViewSet): + permission_classes = (IsAuthenticated,) + serializer_class = ListBoardSerializer + + def get_queryset(self): + queryset = ListBoard.objects.filter(board__user_id=self.request.user.id) + return queryset + + def create(self, request, *args, **kwargs): + board_id = request.data.get('board') + board = Board.objects.get(id=board_id) + if request.user.id != board.user.id: + return Response({"error": "Cannot create ListBoard for another user's board."}, status=status.HTTP_403_FORBIDDEN) + return super().create(request, *args, **kwargs) + diff --git a/backend/core/local_settings.py b/backend/core/local_settings.py new file mode 100644 index 0000000..9e7903c --- /dev/null +++ b/backend/core/local_settings.py @@ -0,0 +1,269 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 4.2.6. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from datetime import timedelta +import os +from pathlib import Path +from decouple import config, Csv + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = config('SECRET_KEY', default='j5&66&8@b-!3tbq!=w6-dypl($_0zzoi*ilxd1*&$_6s-59il5') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = config('DEBUG', default=False, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv()) + + +# Application definition + +SITE_ID = 4 + +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + + 'tasks', + 'users', + 'authentications', + 'dashboard', + 'boards', + + 'corsheaders', + 'drf_spectacular', + + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + + 'rest_framework', + 'dj_rest_auth', + 'dj_rest_auth.registration', + 'rest_framework.authtoken', +] + +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + + ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'dj_rest_auth.jwt_auth.JWTCookieAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'TurTask API', + 'DESCRIPTION': 'API documentation for TurTask', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +REST_USE_JWT = True + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=3), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), +} + +GOOGLE_CLIENT_ID = config('GOOGLE_CLIENT_ID', default='fake-client-id') +GOOGLE_CLIENT_SECRET = config('GOOGLE_CLIENT_SECRET', default='fake-client-secret') + +SOCIALACCOUNT_PROVIDERS = { + 'google': { + 'APP': { + 'client_id': GOOGLE_CLIENT_ID, + 'secret': GOOGLE_CLIENT_SECRET, + 'key': '' + }, + "SCOPE": [ + "profile", + "email", + ], + "AUTH_PARAMS": { + "access_type": "online", + } + } +} + + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [ + "http://localhost:8000", + "http://127.0.0.1:8000", + "http://localhost:5173", +] + +CSRF_TRUSTED_ORIGINS = ["http://localhost:5173"] + +CORS_ORIGIN_WHITELIST = ["*"] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "allauth.account.middleware.AccountMiddleware", +] + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'templates') + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': config('DB_NAME', default='github_actions'), + 'USER': config('DB_USER', default='postgres'), + 'PASSWORD': config('DB_PASSWORD', default='postgres'), + 'HOST': config('DB_HOST', default='127.0.0.1'), + 'PORT': config('DB_PORT', default='5432'), + } +} + + +# Cache + +CACHES_LOCATION = f"{config('DB_NAME', default='db_test')}_cache" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": CACHES_LOCATION, + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +] + +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/' + +AUTH_USER_MODEL = "users.CustomUser" + +ACCOUNT_EMAIL_REQUIRED = True + +# Storages + +AWS_ACCESS_KEY_ID = config('AMAZON_S3_ACCESS_KEY', default='fake-access-key') +AWS_SECRET_ACCESS_KEY = config('AMAZON_S3_SECRET_ACCESS_KEY', default='fake-secret-access-key') +AWS_STORAGE_BUCKET_NAME = config('BUCKET_NAME', default='fake-bucket-name') +AWS_DEFAULT_ACL = 'public-read' +AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' +AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} + +MEDIA_URL = '/mediafiles/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles') + +STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + }, + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} \ No newline at end of file diff --git a/backend/core/production_settings.py b/backend/core/production_settings.py new file mode 100644 index 0000000..dd9eeb3 --- /dev/null +++ b/backend/core/production_settings.py @@ -0,0 +1,267 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 4.2.6. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from datetime import timedelta +import os +from pathlib import Path +from decouple import config, Csv + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = config('SECRET_KEY', default='j5&66&8@b-!3tbq!=w6-dypl($_0zzoi*ilxd1*&$_6s-59il5') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = config('DEBUG', default=False, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv()) + + +# Application definition + +SITE_ID = 4 + +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +) + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + + 'tasks', + 'users', + 'authentications', + 'dashboard', + 'boards', + + 'corsheaders', + 'drf_spectacular', + + 'allauth', + 'allauth.account', + 'allauth.socialaccount', + 'allauth.socialaccount.providers.google', + + 'rest_framework', + 'dj_rest_auth', + 'dj_rest_auth.registration', + 'rest_framework.authtoken', +] + +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + + ], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'dj_rest_auth.jwt_auth.JWTCookieAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'TurTask API', + 'DESCRIPTION': 'API documentation for TurTask', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +REST_USE_JWT = True + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=3), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), +} + +GOOGLE_CLIENT_ID = config('GOOGLE_CLIENT_ID', default='fake-client-id') +GOOGLE_CLIENT_SECRET = config('GOOGLE_CLIENT_SECRET', default='fake-client-secret') + +SOCIALACCOUNT_PROVIDERS = { + 'google': { + 'APP': { + 'client_id': GOOGLE_CLIENT_ID, + 'secret': GOOGLE_CLIENT_SECRET, + 'key': '' + }, + "SCOPE": [ + "profile", + "email", + ], + "AUTH_PARAMS": { + "access_type": "online", + } + } +} + + +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [ + "https://turtask.vercel.app", +] + +CSRF_TRUSTED_ORIGINS = ["https://turtask.vercel.app"] + +CORS_ORIGIN_WHITELIST = ["*"] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "allauth.account.middleware.AccountMiddleware", +] + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + os.path.join(BASE_DIR, 'templates') + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'NAME': config('PGDATABASE', default='github_actions'), + 'USER': config('PGUSER', default='postgres'), + 'PASSWORD': config('PGPASSWORD', default='postgres'), + 'HOST': config('PGHOST', default='127.0.0.1'), + 'PORT': config('PGPORT', default='5432'), + } +} + + +# Cache + +CACHES_LOCATION = "accesstokencache" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.db.DatabaseCache", + "LOCATION": CACHES_LOCATION, + } +} + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = [ + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +] + +LOGIN_REDIRECT_URL = '/' +LOGOUT_REDIRECT_URL = '/' + +AUTH_USER_MODEL = "users.CustomUser" + +ACCOUNT_EMAIL_REQUIRED = True + +# Storages + +AWS_ACCESS_KEY_ID = config('AMAZON_S3_ACCESS_KEY', default='fake-access-key') +AWS_SECRET_ACCESS_KEY = config('AMAZON_S3_SECRET_ACCESS_KEY', default='fake-secret-access-key') +AWS_STORAGE_BUCKET_NAME = config('BUCKET_NAME', default='fake-bucket-name') +AWS_DEFAULT_ACL = 'public-read' +AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' +AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} + +MEDIA_URL = '/mediafiles/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles') + +STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + "OPTIONS": { + }, + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, +} \ No newline at end of file diff --git a/backend/core/settings.py b/backend/core/settings.py index 5c01d05..7532f01 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -1,267 +1,6 @@ -""" -Django settings for core project. - -Generated by 'django-admin startproject' using Django 4.2.6. - -For more information on this file, see -https://docs.djangoproject.com/en/4.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.2/ref/settings/ -""" - -from datetime import timedelta import os -from pathlib import Path -from decouple import config, Csv -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = config('SECRET_KEY', default='j5&66&8@b-!3tbq!=w6-dypl($_0zzoi*ilxd1*&$_6s-59il5') - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = config('DEBUG', default=False, cast=bool) - -ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='*', cast=Csv()) - - -# Application definition - -SITE_ID = 4 - -AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', - 'allauth.account.auth_backends.AuthenticationBackend', -) - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.sites', - - 'tasks', - 'users', - 'authentications', - - 'corsheaders', - 'drf_spectacular', - - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - 'allauth.socialaccount.providers.google', - - 'rest_framework', - 'dj_rest_auth', - 'dj_rest_auth.registration', - 'rest_framework.authtoken', -] - -REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated', - - ], - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework.authentication.BasicAuthentication', - 'rest_framework.authentication.TokenAuthentication', - 'rest_framework_simplejwt.authentication.JWTAuthentication', - 'dj_rest_auth.jwt_auth.JWTCookieAuthentication', - ], - 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', -} - -SPECTACULAR_SETTINGS = { - 'TITLE': 'TurTask API', - 'DESCRIPTION': 'API documentation for TurTask', - 'VERSION': '1.0.0', - 'SERVE_INCLUDE_SCHEMA': False, -} - -REST_USE_JWT = True - -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(days=3), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), -} - -GOOGLE_CLIENT_ID = config('GOOGLE_CLIENT_ID', default='fake-client-id') -GOOGLE_CLIENT_SECRET = config('GOOGLE_CLIENT_SECRET', default='fake-client-secret') - -SOCIALACCOUNT_PROVIDERS = { - 'google': { - 'APP': { - 'client_id': GOOGLE_CLIENT_ID, - 'secret': GOOGLE_CLIENT_SECRET, - 'key': '' - }, - "SCOPE": [ - "profile", - "email", - ], - "AUTH_PARAMS": { - "access_type": "online", - } - } -} - - -CORS_ALLOW_CREDENTIALS = True -CORS_ALLOW_ALL_ORIGINS = False -CORS_ALLOWED_ORIGINS = [ - "http://localhost:8000", - "http://127.0.0.1:8000", - "http://localhost:5173", -] - -CSRF_TRUSTED_ORIGINS = ["http://localhost:5173"] - -CORS_ORIGIN_WHITELIST = ["*"] - -MIDDLEWARE = [ - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - "allauth.account.middleware.AccountMiddleware", -] - -ROOT_URLCONF = 'core.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [ - os.path.join(BASE_DIR, 'templates') - ], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'core.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/4.2/ref/settings/#databases - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': config('DB_NAME', default='github_actions'), - 'USER': config('DB_USER', default='postgres'), - 'PASSWORD': config('DB_PASSWORD', default='postgres'), - 'HOST': config('DB_HOST', default='127.0.0.1'), - 'PORT': config('DB_PORT', default='5432'), - } -} - - -# Cache - -CACHES_LOCATION = f"{config('DB_NAME', default='db_test')}_cache" - -CACHES = { - "default": { - "BACKEND": "django.core.cache.backends.db.DatabaseCache", - "LOCATION": CACHES_LOCATION, - } -} - -# Password validation -# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/4.2/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.2/howto/static-files/ - -STATIC_URL = 'static/' - -# Default primary key field type -# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - - -SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = [ - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/userinfo.profile', -] - -LOGIN_REDIRECT_URL = '/' -LOGOUT_REDIRECT_URL = '/' - -AUTH_USER_MODEL = "users.CustomUser" - -ACCOUNT_EMAIL_REQUIRED = True - -# Storages - -AWS_ACCESS_KEY_ID = config('AMAZON_S3_ACCESS_KEY', default='fake-access-key') -AWS_SECRET_ACCESS_KEY = config('AMAZON_S3_SECRET_ACCESS_KEY', default='fake-secret-access-key') -AWS_STORAGE_BUCKET_NAME = config('BUCKET_NAME', default='fake-bucket-name') -AWS_DEFAULT_ACL = 'public-read' -AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com' -AWS_S3_OBJECT_PARAMETERS = {'CacheControl': 'max-age=86400'} - -MEDIA_URL = '/mediafiles/' -MEDIA_ROOT = os.path.join(BASE_DIR, 'mediafiles') - -STORAGES = { - "default": { - "BACKEND": "storages.backends.s3.S3Storage", - "OPTIONS": { - }, - }, - "staticfiles": { - "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", - }, -} \ No newline at end of file +if os.environ.get("DJANGO_ENV") == "PRODUCTION": + from .production_settings import * +else: + from .local_settings import * \ No newline at end of file diff --git a/backend/core/urls.py b/backend/core/urls.py index a02869c..3ba1133 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -27,4 +27,6 @@ urlpatterns = [ path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + path('api/', include('dashboard.urls')), + path('api/', include('boards.urls')), ] \ No newline at end of file diff --git a/backend/core/wsgi.py b/backend/core/wsgi.py index f44964d..a08c895 100644 --- a/backend/core/wsgi.py +++ b/backend/core/wsgi.py @@ -11,6 +11,6 @@ import os from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "core.settings") application = get_wsgi_application() diff --git a/backend/dashboard/__init__.py b/backend/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/dashboard/admin.py b/backend/dashboard/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/backend/dashboard/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/backend/dashboard/apps.py b/backend/dashboard/apps.py new file mode 100644 index 0000000..7b1cc05 --- /dev/null +++ b/backend/dashboard/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class DashboardConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'dashboard' diff --git a/backend/dashboard/migrations/__init__.py b/backend/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/dashboard/serializers.py b/backend/dashboard/serializers.py new file mode 100644 index 0000000..ddc207b --- /dev/null +++ b/backend/dashboard/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models import UserStats + +class UserStatsSerializer(serializers.ModelSerializer): + class Meta: + model = UserStats + fields = ['health', 'gold', 'experience', 'strength', 'intelligence', 'endurance', 'perception', 'luck', 'level'] \ No newline at end of file diff --git a/backend/dashboard/tests.py b/backend/dashboard/tests.py new file mode 100644 index 0000000..943c3de --- /dev/null +++ b/backend/dashboard/tests.py @@ -0,0 +1,103 @@ +from django.test import TestCase +from django.urls import reverse +from tasks.models import Todo +from django.utils import timezone +from datetime import timedelta + +from tasks.tests.utils import create_test_user, login_user + +class DashboardStatsAndWeeklyViewSetTests(TestCase): + def setUp(self): + self.user = create_test_user() + self.client = login_user(self.user) + + def create_task(self, title, completed=False, completion_date=None, end_event=None): + return Todo.objects.create( + user=self.user, + title=title, + completed=completed, + completion_date=completion_date, + end_event=end_event + ) + + def test_dashboard_stats_view(self): + # Create tasks for testing + self.create_task('Task 1', completed=True) + self.create_task('Task 2', end_event=timezone.now() - timedelta(days=8)) + self.create_task('Task 3', end_event=timezone.now()) + + response = self.client.get(reverse('stats-list')) + self.assertEqual(response.status_code, 200) + + self.assertEqual(response.data['completed_this_week'], 1) + self.assertEqual(response.data['tasks_assigned_this_week'], 1) + self.assertEqual(response.data['tasks_assigned_last_week'], 0) + + def test_dashboard_weekly_view(self): + # Create tasks for testing + self.create_task('Task 1', completion_date=timezone.now() - timedelta(days=1)) + self.create_task('Task 2', end_event=timezone.now() - timedelta(days=8)) + self.create_task('Task 3', end_event=timezone.now()) + + response = self.client.get(reverse('weekly-list')) + self.assertEqual(response.status_code, 200) + + +# class DashboardStatsAPITestCase(TestCase): +# def setUp(self): +# # Create a test user +# self.user = create_test_user() + +# # Create test tasks +# self.todo = Todo.objects.create(user=self.user, title='Test Todo') +# self.recurrence_task = RecurrenceTask.objects.create(user=self.user, title='Test Recurrence Task') + +# # Create an API client +# self.client = APIClient() + +# def test_dashboard_stats_api(self): +# # Authenticate the user +# self.client.force_authenticate(user=self.user) + +# # Make a GET request to the DashboardStatsAPIView +# response = self.client.get(reverse("dashboard-stats")) + +# # Assert the response status code is 200 +# self.assertEqual(response.status_code, 200) + +# def test_task_completion_status_update(self): +# # Authenticate the user +# self.client.force_authenticate(user=self.user) + +# # Make a POST request to update the completion status of a task +# data = {'task_id': self.todo.id, 'is_completed': True} +# response = self.client.post(reverse("dashboard-stats"), data, format='json') + +# # Assert the response status code is 200 +# self.assertEqual(response.status_code, 200) + +# # Assert the message in the response +# self.assertEqual(response.data['message'], 'Task completion status updated successfully') + +# # Refresh the todo instance from the database and assert the completion status +# self.todo.refresh_from_db() +# self.assertTrue(self.todo.completed) + + +# class WeeklyStatsAPITestCase(TestCase): +# def setUp(self): +# # Create a test user +# self.user = create_test_user() + +# # Create an API client +# self.client = APIClient() + +# def test_weekly_stats_api(self): +# # Authenticate the user +# self.client.force_authenticate(user=self.user) + +# # Make a GET request to the WeeklyStatsAPIView +# response = self.client.get(reverse('dashboard-weekly-stats')) + +# # Assert the response status code is 200 +# self.assertEqual(response.status_code, 200) diff --git a/backend/dashboard/urls.py b/backend/dashboard/urls.py new file mode 100644 index 0000000..56624ca --- /dev/null +++ b/backend/dashboard/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import DashboardStatsViewSet, DashboardWeeklyViewSet + +router = DefaultRouter() +router.register(r'dashboard/stats', DashboardStatsViewSet, basename='stats') +router.register(r'dashboard/weekly', DashboardWeeklyViewSet, basename='weekly') +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/dashboard/views.py b/backend/dashboard/views.py new file mode 100644 index 0000000..abe0576 --- /dev/null +++ b/backend/dashboard/views.py @@ -0,0 +1,311 @@ +from datetime import timedelta +from django.utils import timezone +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework import viewsets, mixins + +from tasks.models import Todo + + +class DashboardStatsViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + return Todo.objects.all() + + def list(self, request, *args, **kwargs): + user = self.request.user + + # Calculate the start and end date for the last 7 days + end_date = timezone.now() + start_date = end_date - timedelta(days=7) + + # How many tasks were completed in the last 7 days + completed_last_7_days = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=start_date, + completion_date__lte=end_date + ).count() + + # Task assign last week compared with this week + tasks_assigned_last_week = Todo.objects.filter( + user=user, + completion_date__gte=start_date - timedelta(days=7), + completion_date__lte=start_date + ).count() + + tasks_assigned_this_week = Todo.objects.filter( + user=user, + completion_date__gte=start_date, + completion_date__lte=end_date + ).count() + + # Completed tasks from last week compared with this week + completed_last_week = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=start_date - timedelta(days=7), + completion_date__lte=start_date + ).count() + + completed_this_week = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=start_date, + completion_date__lte=end_date + ).count() + + overdue_tasks = Todo.objects.filter( + user=user, + completed=False, + end_event__lt=timezone.now() + ).count() + + # Overall completion rate + total_tasks = Todo.objects.filter(user=user).count() + overall_completion_rate = (completed_last_7_days / total_tasks) * 100 if total_tasks > 0 else 0 + + data = { + "completed_last_7_days": completed_last_7_days, + "tasks_assigned_last_week": tasks_assigned_last_week, + "tasks_assigned_this_week": tasks_assigned_this_week, + "completed_last_week": completed_last_week, + "completed_this_week": completed_this_week, + "overdue_tasks": overdue_tasks, + "overall_completion_rate": overall_completion_rate, + } + + return Response(data, status=status.HTTP_200_OK) + + +class DashboardWeeklyViewSet(viewsets.GenericViewSet, mixins.ListModelMixin): + permission_classes = (IsAuthenticated,) + + def get_queryset(self): + return Todo.objects.all() + + def list(self, request, *args, **kwargs): + user = self.request.user + + # Calculate the start and end date for the last 7 days (Monday to Sunday) + today = timezone.now().date() + current_week_start = today - timedelta(days=today.weekday()) + current_week_end = current_week_start + timedelta(days=6) + + last_week_start = current_week_start - timedelta(days=7) + last_week_end = last_week_start + timedelta(days=6) + + # Create a list to store daily statistics + weekly_stats = [] + + # Iterate over each day of the week + for day in range(7): + current_day = current_week_start + timedelta(days=day) + last_day = last_week_start + timedelta(days=day) + + # Calculate stats for this week + tasks_this_week = Todo.objects.filter( + user=user, + completion_date__gte=current_day, + completion_date__lte=current_day + timedelta(days=1) + ).count() + + completed_this_week = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=current_day, + completion_date__lte=current_day + timedelta(days=1) + ).count() + + # Calculate stats for last week + tasks_last_week = Todo.objects.filter( + user=user, + completion_date__gte=last_day, + completion_date__lte=last_day + timedelta(days=1) + ).count() + + completed_last_week = Todo.objects.filter( + user=user, + completed=True, + completion_date__gte=last_day, + completion_date__lte=last_day + timedelta(days=1) + ).count() + + daily_stat = { + "date": current_day.strftime("%A"), + "This Week": tasks_this_week, + "Last Week": tasks_last_week, + "Completed This Week": completed_this_week, + "Completed Last Week": completed_last_week, + } + + weekly_stats.append(daily_stat) + + return Response(weekly_stats, status=status.HTTP_200_OK) + + +# class DashboardStatsAPIView(APIView): +# permission_classes = [IsAuthenticated] + +# def get(self, request): +# user = request.user + +# # Calculate task usage statistics +# todo_count = Todo.objects.filter(user=user).count() +# recurrence_task_count = RecurrenceTask.objects.filter(user=user).count() + +# # Calculate how many tasks were completed in the last 7 days +# completed_todo_count_last_week = Todo.objects.filter(user=user, completed=True, last_update__gte=timezone.now() - timezone.timedelta(days=7)).count() +# completed_recurrence_task_count_last_week = RecurrenceTask.objects.filter(user=user, completed=True, last_update__gte=timezone.now() - timezone.timedelta(days=7)).count() + +# # Calculate subtask completion rate +# total_subtasks = Todo.objects.filter(user=user).aggregate(total=Count('subtask__id'))['total'] +# completed_subtasks = Todo.objects.filter(user=user, subtask__completed=True).aggregate(total=Count('subtask__id'))['total'] + +# # Calculate overall completion rate +# total_tasks = todo_count + recurrence_task_count +# completed_tasks = completed_todo_count_last_week + completed_recurrence_task_count_last_week +# overall_completion_rate = (completed_tasks / total_tasks) * 100 if total_tasks > 0 else 0 + +# # pie chart show +# complete_todo_percent_last_week = (completed_todo_count_last_week / todo_count) * 100 if todo_count > 0 else 0 + +# complete_recurrence_percent_last_week = (completed_recurrence_task_count_last_week / recurrence_task_count) * 100 if recurrence_task_count > 0 else 0 + +# incomplete_task_percent_last_week = 100 - complete_recurrence_percent_last_week - complete_todo_percent_last_week + +# data = { +# 'todo_count': todo_count, +# 'recurrence_task_count': recurrence_task_count, +# 'completed_todo_count_last_week': completed_todo_count_last_week, +# 'completed_recurrence_task_count_last_week': completed_recurrence_task_count_last_week, +# 'total_subtasks': total_subtasks, +# 'completed_subtasks': completed_subtasks, +# 'overall_completion_rate': overall_completion_rate, +# 'complete_todo_percent_last_week': complete_todo_percent_last_week, +# 'complete_recurrence_percent_last_week' : complete_recurrence_percent_last_week, +# 'incomplete_task_percent_last_week': incomplete_task_percent_last_week, + +# } + +# return Response(data, status=status.HTTP_200_OK) + +# def post(self, request): +# # Handle incoming data from the POST request +# # Update the necessary information based on the data + +# task_id = request.data.get('task_id') +# is_completed = request.data.get('is_completed') + +# try: +# task = Todo.objects.get(id=task_id, user=request.user) +# task.completed = is_completed +# task.save() +# return Response({'message': 'Task completion status updated successfully'}, status=status.HTTP_200_OK) +# except Todo.DoesNotExist: +# return Response({'error': 'Task not found'}, status=status.HTTP_404_NOT_FOUND) + +# class WeeklyStatsAPIView(APIView): +# permission_classes = [IsAuthenticated] + +# def get(self, request): +# user = request.user +# today = timezone.now() + +# # Calculate the start and end dates for the current week +# current_week_start = today - timezone.timedelta(days=today.weekday()) +# current_week_end = current_week_start + timezone.timedelta(days=6) + +# # Initialize a list to store daily statistics +# weekly_stats = [] + +# # Loop through each day of the week +# for i in range(7): +# # Calculate the start and end dates for the current day +# current_day_start = current_week_start + timezone.timedelta(days=i) +# current_day_end = current_day_start + timezone.timedelta(days=1) + +# # Calculate the start and end dates for the same day over the last 7 days +# last_7_days_start = current_day_start - timezone.timedelta(days=7) +# last_7_days_end = current_day_end - timezone.timedelta(days=7) + +# # Calculate statistics for the current day +# current_day_stats = self.calculate_stats(user, current_day_start, current_day_end) + +# # Calculate statistics for the same day over the last 7 days +# last_7_days_stats = self.calculate_stats(user, last_7_days_start, last_7_days_end) + +# # Calculate the percentage change +# percent_change_over_all = self.calculate_percent_change( +# current_day_stats['overall_completion_rate'], +# last_7_days_stats['overall_completion_rate'] +# ) + +# # Calculate percentage change for completed_todo_count +# percent_change_todo = self.calculate_percent_change( +# current_day_stats['completed_todo_count'], +# last_7_days_stats['completed_todo_count'] +# ) + +# # Calculate percentage change for completed_recurrence_task_count +# percent_change_recurrence = self.calculate_percent_change( +# current_day_stats['completed_recurrence_task_count'], +# last_7_days_stats['completed_recurrence_task_count'] +# ) + +# # Append the daily statistics to the list +# weekly_stats.append({ +# 'day_of_week': current_day_start.strftime('%A'), +# 'current_day_stats': current_day_stats, +# 'last_7_days_stats': last_7_days_stats, +# 'percent_change_over_all': percent_change_over_all, +# 'percent_change_todo': percent_change_todo, +# 'percent_change_recurrence': percent_change_recurrence, +# }) + +# response_data = { +# 'weekly_stats': weekly_stats, +# } + +# return Response(response_data, status=status.HTTP_200_OK) + +# def calculate_stats(self, user, start_date, end_date): +# # Calculate task usage statistics for the specified day +# todo_count = Todo.objects.filter(user=user, created_at__gte=start_date, created_at__lte=end_date).count() +# recurrence_task_count = RecurrenceTask.objects.filter(user=user, created_at__gte=start_date, created_at__lte=end_date).count() + +# # Calculate how many tasks were completed on the specified day +# completed_todo_count = Todo.objects.filter(user=user, completed=True, last_update__gte=start_date, last_update__lte=end_date).count() +# completed_recurrence_task_count = RecurrenceTask.objects.filter(user=user, completed=True, last_update__gte=start_date, last_update__lte=end_date).count() + +# # Calculate subtask completion rate for the specified day +# total_subtasks = Todo.objects.filter(user=user, created_at__gte=start_date, created_at__lte=end_date).aggregate(total=Count('subtask__id'))['total'] +# completed_subtasks = Todo.objects.filter(user=user, subtask__completed=True, created_at__gte=start_date, created_at__lte=end_date).aggregate(total=Count('subtask__id'))['total'] + +# # Calculate overall completion rate for the specified day +# total_tasks = todo_count + recurrence_task_count +# completed_tasks = completed_todo_count + completed_recurrence_task_count +# overall_completion_rate = (completed_tasks / total_tasks) * 100 if total_tasks > 0 else 0 + +# return { +# 'start_date': start_date.strftime('%Y-%m-%d'), +# 'end_date': end_date.strftime('%Y-%m-%d'), +# 'todo_count': todo_count, +# 'recurrence_task_count': recurrence_task_count, +# 'completed_todo_count': completed_todo_count, +# 'completed_recurrence_task_count': completed_recurrence_task_count, +# 'total_subtasks': total_subtasks, +# 'completed_subtasks': completed_subtasks, +# 'overall_completion_rate': overall_completion_rate, +# } + +# def calculate_percent_change(self, current_value, last_value): +# # Calculate the percentage change between current and last values +# if last_value != 0: +# percent_change = ((current_value - last_value) / last_value) * 100 +# else: +# percent_change = current_value * 100 # Consider infinite change when the last value is zero + +# return round(percent_change, 2) diff --git a/backend/railway.json b/backend/railway.json new file mode 100644 index 0000000..a99f1a8 --- /dev/null +++ b/backend/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "startCommand": "python manage.py migrate && python manage.py createcachetable accesstokencache && gunicorn --timeout 500 core.wsgi", + "restartPolicyType": "NEVER", + "restartPolicyMaxRetries": 10 + } +} diff --git a/requirements.txt b/backend/requirements.txt similarity index 82% rename from requirements.txt rename to backend/requirements.txt index 98f2a15..95962f8 100644 --- a/requirements.txt +++ b/backend/requirements.txt @@ -14,4 +14,7 @@ google_auth_oauthlib>=1.1 google-auth-httplib2>=0.1 django-storages[s3]>=1.14 Pillow>=10.1 -drf-spectacular>=0.26 \ No newline at end of file +drf-spectacular>=0.26 +python-dateutil>=2.8 +gunicorn==21.2.0 +packaging==23.1 \ No newline at end of file diff --git a/backend/sample.env b/backend/sample.env index 15b50eb..b186167 100644 --- a/backend/sample.env +++ b/backend/sample.env @@ -7,4 +7,7 @@ DB_PASSWORD=your_DB_PASSWORD DB_HOST=your_DB_HOST DB_PORT=your_DB_PORT GOOGLE_CLIENT_ID=your_GOOGLE_CLIENT_ID -GOOGLE_CLIENT_SECRET=your_GOOGLE_CLIENT_SECRET \ No newline at end of file +GOOGLE_CLIENT_SECRET=your_GOOGLE_CLIENT_SECRET +BUCKET_NAME=your_BUCKET_NAME +AMAZON_S3_ACCESS_KEY=YOUR_S3_ACCESS_KEY +AMAZON_S3_SECRET_ACCESS_KEY=YOUR_S3_SECRET diff --git a/backend/tasks/admin.py b/backend/tasks/admin.py index 8c38f3f..cabf594 100644 --- a/backend/tasks/admin.py +++ b/backend/tasks/admin.py @@ -1,3 +1,30 @@ from django.contrib import admin +from .models import Tag, Todo, RecurrenceTask, RecurrencePattern, Habit, Subtask -# Register your models here. +@admin.register(Tag) +class TagAdmin(admin.ModelAdmin): + list_display = ['name'] + +@admin.register(Todo) +class TodoAdmin(admin.ModelAdmin): + list_display = ['title', 'list_board', 'is_active', 'priority', 'completed', 'completion_date'] + list_filter = ['list_board', 'is_active', 'priority', 'completed'] + exclude = ['completion_date'] + +@admin.register(RecurrenceTask) +class RecurrenceTaskAdmin(admin.ModelAdmin): + list_display = ['title', 'list_board', 'rrule', 'is_active'] + list_filter = ['list_board', 'rrule', 'is_active'] + +@admin.register(RecurrencePattern) +class RecurrencePatternAdmin(admin.ModelAdmin): + list_display = ['recurrence_task', 'recurring_type', 'day_of_week', 'week_of_month', 'day_of_month', 'month_of_year'] + +@admin.register(Habit) +class HabitAdmin(admin.ModelAdmin): + list_display = ['title', 'streak', 'current_count'] + +@admin.register(Subtask) +class SubtaskAdmin(admin.ModelAdmin): + list_display = ['parent_task', 'description', 'completed'] + list_filter = ['parent_task', 'completed'] diff --git a/backend/tasks/api.py b/backend/tasks/api.py index 7669d76..fdf2493 100644 --- a/backend/tasks/api.py +++ b/backend/tasks/api.py @@ -7,45 +7,21 @@ from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from tasks.utils import get_service -from tasks.models import Todo, RecurrenceTask -from tasks.serializers import TodoUpdateSerializer, RecurrenceTaskUpdateSerializer - +from tasks.models import Todo +from tasks.serializers import TodoUpdateSerializer class GoogleCalendarEventViewset(viewsets.ViewSet): + """Viewset for list or save Google Calendar Events.""" permission_classes = (IsAuthenticated,) def __init__(self, *args, **kwargs): super().__init__() - self.current_time = datetime.now(tz=timezone.utc).isoformat() - self.event_fields = 'items(id,summary,description,created,recurringEventId,updated,start,end)' + self.current_time = (datetime.now(tz=timezone.utc) + timedelta(days=-7)).isoformat() + self.max_time = (datetime.now(tz=timezone.utc) + timedelta(days=7)).isoformat() + self.event_fields = 'items(id,summary,description,created,recurringEventId,updated,start,end,originalStartTime)' - def _validate_serializer(self, serializer): - if serializer.is_valid(): - serializer.save() - return Response("Validate Successfully", status=200) - return Response(serializer.errors, status=400) - - def post(self, request): - service = get_service(request) - events = service.events().list(calendarId='primary', fields=self.event_fields).execute() - for event in events.get('items', []): - if event.get('recurringEventId'): - continue - event['start_datetime'] = event.get('start').get('dateTime') - event['end_datetime'] = event.get('end').get('dateTime') - event.pop('start') - event.pop('end') - try: - task = Todo.objects.get(google_calendar_id=event['id']) - serializer = TodoUpdateSerializer(instance=task, data=event) - return self._validate_serializer(serializer) - except Todo.DoesNotExist: - serializer = TodoUpdateSerializer(data=event, user=request.user) - return self._validate_serializer(serializer) - - def list(self, request, days=7): - max_time = (datetime.now(tz=timezone.utc) + timedelta(days=days)).isoformat() - + def _get_google_events(self, request): + """Get all events from Google Calendar. """ service = get_service(request) events = [] next_page_token = None @@ -54,21 +30,61 @@ class GoogleCalendarEventViewset(viewsets.ViewSet): query = service.events().list( calendarId='primary', timeMin=self.current_time, - timeMax=max_time, + timeMax=self.max_time, maxResults=200, singleEvents=True, orderBy='startTime', pageToken=next_page_token, - fields='items(id,summary,description,created,recurringEventId,updated,start,end)', + fields=self.event_fields, ) page_results = query.execute() page_events = page_results.get('items', []) - + events.extend(page_events) next_page_token = page_results.get('nextPageToken') if next_page_token is None: break - return Response(events, status=200) \ No newline at end of file + return events + + def _validate_serializer(self, serializer): + """ + Validate serializer and return response. + + :param serializer: The serializer to validate. + """ + if serializer.is_valid(): + serializer.save() + return Response("Validate Successfully", status=200) + return Response(serializer.errors, status=400) + + def create(self, request, *args, **kwargs): + """Create a new Google Calendar Event.""" + events = self._get_google_events(request) + + responses = [] + for event in events: + start_datetime = event.get('start', {}).get('dateTime') + end_datetime = event.get('end', {}).get('dateTime') + + event['start_datetime'] = start_datetime + event['end_datetime'] = end_datetime + event.pop('start') + event.pop('end') + + try: + task = Todo.objects.get(google_calendar_id=event['id']) + serializer = TodoUpdateSerializer(instance=task, data=event) + except Todo.DoesNotExist: + serializer = TodoUpdateSerializer(data=event, user=request.user) + + responses.append(self._validate_serializer(serializer)) + + return responses[0] if responses else Response("No events to process", status=200) + + def list(self, request): + """List all Google Calendar Events.""" + return Response(self._get_google_events(request), status=200) + \ No newline at end of file diff --git a/backend/tasks/migrations/0012_habit.py b/backend/tasks/migrations/0012_habit.py new file mode 100644 index 0000000..6e29775 --- /dev/null +++ b/backend/tasks/migrations/0012_habit.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.6 on 2023-11-13 18:15 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tasks', '0011_recurrencetask'), + ] + + operations = [ + migrations.CreateModel( + name='Habit', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField()), + ('notes', models.TextField(default='')), + ('importance', models.PositiveSmallIntegerField(choices=[(1, '1'), (2, '2'), (3, '3'), (4, '4'), (5, '5')], default=1)), + ('difficulty', models.PositiveSmallIntegerField(choices=[(1, 'Easy'), (2, 'Normal'), (3, 'Hard'), (4, 'Very Hard'), (5, 'Devil')], default=1)), + ('challenge', models.BooleanField(default=False)), + ('fromSystem', models.BooleanField(default=False)), + ('creation_date', models.DateTimeField(auto_now_add=True)), + ('last_update', models.DateTimeField(auto_now=True)), + ('google_calendar_id', models.CharField(blank=True, max_length=255, null=True)), + ('start_event', models.DateTimeField(null=True)), + ('end_event', models.DateTimeField(null=True)), + ('streak', models.IntegerField(default=0)), + ('tags', models.ManyToManyField(blank=True, to='tasks.tag')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/tasks/migrations/0013_alter_recurrencetask_recurrence_rule.py b/backend/tasks/migrations/0013_alter_recurrencetask_recurrence_rule.py new file mode 100644 index 0000000..f629f13 --- /dev/null +++ b/backend/tasks/migrations/0013_alter_recurrencetask_recurrence_rule.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.6 on 2023-11-14 15:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0012_habit'), + ] + + operations = [ + migrations.AlterField( + model_name='recurrencetask', + name='recurrence_rule', + field=models.CharField(), + ), + ] diff --git a/backend/tasks/migrations/0014_recurrencetask_completed_todo_completed.py b/backend/tasks/migrations/0014_recurrencetask_completed_todo_completed.py new file mode 100644 index 0000000..d89360d --- /dev/null +++ b/backend/tasks/migrations/0014_recurrencetask_completed_todo_completed.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.6 on 2023-11-17 16:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0013_alter_recurrencetask_recurrence_rule'), + ] + + operations = [ + migrations.AddField( + model_name='recurrencetask', + name='completed', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='todo', + name='completed', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/tasks/migrations/0015_recurrencepattern_remove_transaction_user_and_more.py b/backend/tasks/migrations/0015_recurrencepattern_remove_transaction_user_and_more.py new file mode 100644 index 0000000..ee9c4bc --- /dev/null +++ b/backend/tasks/migrations/0015_recurrencepattern_remove_transaction_user_and_more.py @@ -0,0 +1,107 @@ +# Generated by Django 4.2.6 on 2023-11-19 20:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('boards', '0001_initial'), + ('tasks', '0014_recurrencetask_completed_todo_completed'), + ] + + operations = [ + migrations.CreateModel( + name='RecurrencePattern', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('recurring_type', models.IntegerField(choices=[(0, 'Daily'), (1, 'Weekly'), (2, 'Monthly'), (3, 'Yearly')])), + ('max_occurrences', models.IntegerField(default=0)), + ('day_of_week', models.IntegerField(choices=[(0, 'Monday'), (1, 'Tuesday'), (2, 'Wednesday'), (3, 'Thursday'), (4, 'Friday'), (5, 'Saturday'), (6, 'Sunday')])), + ('week_of_month', models.IntegerField(choices=[(1, 'First'), (2, 'Second'), (3, 'Third'), (4, 'Fourth'), (5, 'Last')])), + ('day_of_month', models.IntegerField(default=0)), + ('month_of_year', models.IntegerField(choices=[(1, 'January'), (2, 'February'), (3, 'March'), (4, 'April'), (5, 'May'), (6, 'June'), (7, 'July'), (8, 'August'), (9, 'September'), (10, 'October'), (11, 'November'), (12, 'December')])), + ], + ), + migrations.RemoveField( + model_name='transaction', + name='user', + ), + migrations.DeleteModel( + name='UserNotification', + ), + migrations.RemoveField( + model_name='habit', + name='end_event', + ), + migrations.RemoveField( + model_name='habit', + name='google_calendar_id', + ), + migrations.RemoveField( + model_name='habit', + name='start_event', + ), + migrations.RemoveField( + model_name='recurrencetask', + name='google_calendar_id', + ), + migrations.RemoveField( + model_name='recurrencetask', + name='recurrence_rule', + ), + migrations.AddField( + model_name='habit', + name='current_count', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='recurrencetask', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='recurrencetask', + name='is_full_day_event', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='recurrencetask', + name='list_board', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + migrations.AddField( + model_name='recurrencetask', + name='parent_task', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='tasks.recurrencetask'), + ), + migrations.AddField( + model_name='recurrencetask', + name='rrule', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='todo', + name='is_active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='todo', + name='is_full_day_event', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='todo', + name='list_board', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + migrations.DeleteModel( + name='Transaction', + ), + migrations.AddField( + model_name='recurrencepattern', + name='recurrence_task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tasks.recurrencetask'), + ), + ] diff --git a/backend/tasks/migrations/0016_alter_recurrencetask_list_board_and_more.py b/backend/tasks/migrations/0016_alter_recurrencetask_list_board_and_more.py new file mode 100644 index 0000000..b0edc6e --- /dev/null +++ b/backend/tasks/migrations/0016_alter_recurrencetask_list_board_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.6 on 2023-11-19 20:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('boards', '0001_initial'), + ('tasks', '0015_recurrencepattern_remove_transaction_user_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='recurrencetask', + name='list_board', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + migrations.AlterField( + model_name='todo', + name='list_board', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + ] diff --git a/backend/tasks/migrations/0017_alter_recurrencetask_list_board_and_more.py b/backend/tasks/migrations/0017_alter_recurrencetask_list_board_and_more.py new file mode 100644 index 0000000..dee431c --- /dev/null +++ b/backend/tasks/migrations/0017_alter_recurrencetask_list_board_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.6 on 2023-11-19 20:27 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('boards', '0001_initial'), + ('tasks', '0016_alter_recurrencetask_list_board_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='recurrencetask', + name='list_board', + field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + migrations.AlterField( + model_name='todo', + name='list_board', + field=models.ForeignKey(default=1, null=True, on_delete=django.db.models.deletion.CASCADE, to='boards.listboard'), + ), + ] diff --git a/backend/tasks/migrations/0018_alter_habit_creation_date_and_more.py b/backend/tasks/migrations/0018_alter_habit_creation_date_and_more.py new file mode 100644 index 0000000..bbb1714 --- /dev/null +++ b/backend/tasks/migrations/0018_alter_habit_creation_date_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.6 on 2023-11-20 14:58 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0017_alter_recurrencetask_list_board_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='habit', + name='creation_date', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AlterField( + model_name='recurrencetask', + name='creation_date', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + migrations.AlterField( + model_name='todo', + name='creation_date', + field=models.DateTimeField(default=django.utils.timezone.now, editable=False), + ), + ] diff --git a/backend/tasks/migrations/0019_alter_habit_creation_date_and_more.py b/backend/tasks/migrations/0019_alter_habit_creation_date_and_more.py new file mode 100644 index 0000000..0ba0307 --- /dev/null +++ b/backend/tasks/migrations/0019_alter_habit_creation_date_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.6 on 2023-11-20 15:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0018_alter_habit_creation_date_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='habit', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='recurrencetask', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='todo', + name='creation_date', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/backend/tasks/migrations/0020_recurrencetask_completion_date_todo_completion_date.py b/backend/tasks/migrations/0020_recurrencetask_completion_date_todo_completion_date.py new file mode 100644 index 0000000..f1ffe13 --- /dev/null +++ b/backend/tasks/migrations/0020_recurrencetask_completion_date_todo_completion_date.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.6 on 2023-11-20 15:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tasks', '0019_alter_habit_creation_date_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='recurrencetask', + name='completion_date', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='todo', + name='completion_date', + field=models.DateTimeField(null=True), + ), + ] diff --git a/backend/tasks/models.py b/backend/tasks/models.py index 5da815e..c848e00 100644 --- a/backend/tasks/models.py +++ b/backend/tasks/models.py @@ -1,5 +1,8 @@ from django.db import models from django.conf import settings +from django.utils import timezone + +from boards.models import ListBoard, Board class Tag(models.Model): """ @@ -12,22 +15,18 @@ class Tag(models.Model): class Task(models.Model): """ - Represents a Abstract of task, such as Habit, Daily, Todo, or Reward. + Represents a Abstract of task, such as Habit, Recurrence, Todo. :param user: The user who owns the task. :param title: Title of the task. :param notes: Optional additional notes for the task. :param tags: Associated tags for the task. - :param completed: A boolean field indicating whether the task is completed. :param importance: The importance of the task (range: 1 to 5) :param difficulty: The difficulty of the task (range: 1 to 5). :param challenge: Associated challenge (optional). :param fromSystem: A boolean field indicating if the task is from System. :param creation_date: Creation date of the task. - :param last_update: Last updated date of the task. - :param: google_calendar_id: Google Calendar Event ID of the task. - :param start_event: Start event of the task. - :param end_event: End event(Due Date) of the task. + :param last_update: Last update date of the task. """ class Difficulty(models.IntegerChoices): EASY = 1, 'Easy' @@ -46,33 +45,157 @@ class Task(models.Model): fromSystem = models.BooleanField(default=False) creation_date = models.DateTimeField(auto_now_add=True) last_update = models.DateTimeField(auto_now=True) - google_calendar_id = models.CharField(max_length=255, null=True, blank=True) - start_event = models.DateTimeField(null=True) - end_event = models.DateTimeField(null=True) class Meta: abstract = True class Todo(Task): - + """ + Represent a Todo task. + + :param list_board: The list board that the task belongs to. + :param is_active: A boolean field indicating whether the task is active. (Archive or not) + :param is_full_day_event: A boolean field indicating whether the task is a full day event. + :param start_event: Start date and time of the task. + :param end_event: End date and time of the task. + :param google_calendar_id: The Google Calendar ID of the task. + :param completed: A boolean field indicating whether the task is completed. + :param completion_date: The date and time when the task is completed. + :param priority: The priority of the task (range: 1 to 4). + """ class EisenhowerMatrix(models.IntegerChoices): IMPORTANT_URGENT = 1, 'Important & Urgent' IMPORTANT_NOT_URGENT = 2, 'Important & Not Urgent' NOT_IMPORTANT_URGENT = 3, 'Not Important & Urgent' NOT_IMPORTANT_NOT_URGENT = 4, 'Not Important & Not Urgent' + list_board = models.ForeignKey(ListBoard, on_delete=models.CASCADE, null=True, default=1) + is_active = models.BooleanField(default=True) + is_full_day_event = models.BooleanField(default=False) + start_event = models.DateTimeField(null=True) + end_event = models.DateTimeField(null=True) + google_calendar_id = models.CharField(max_length=255, null=True, blank=True) + completed = models.BooleanField(default=False) + completion_date = models.DateTimeField(null=True) priority = models.PositiveSmallIntegerField(choices=EisenhowerMatrix.choices, default=EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT) + def save(self, *args, **kwargs): + if self.completed and not self.completion_date: + self.completion_date = timezone.now() + elif not self.completed: + self.completion_date = None + + super().save(*args, **kwargs) + def __str__(self): return self.title class RecurrenceTask(Task): - recurrence_rule = models.TextField() + """ + Represent a Recurrence task. (Occure every day, week, month, year) + + :param list_board: The list board that the task belongs to. + :param rrule: The recurrence rule of the task. + :param is_active: A boolean field indicating whether the task is active. (Archive or not) + :param is_full_day_event: A boolean field indicating whether the task is a full day event. + :param start_event: Start date and time of the task. + :param end_event: End date and time of the task. + :param completed: A boolean field indicating whether the task is completed. + :param completion_date: The date and time when the task is completed. + :param parent_task: The parent task of the subtask. + """ + list_board = models.ForeignKey(ListBoard, on_delete=models.CASCADE, null=True, default=1) + rrule = models.CharField(max_length=255, null=True, blank=True) + is_active = models.BooleanField(default=True) + is_full_day_event = models.BooleanField(default=False) + start_event = models.DateTimeField(null=True) + end_event = models.DateTimeField(null=True) + completed = models.BooleanField(default=False) + completion_date = models.DateTimeField(null=True) + parent_task = models.ForeignKey("self", on_delete=models.CASCADE, null=True) + + def save(self, *args, **kwargs): + if self.completed and not self.completion_date: + self.completion_date = timezone.now() + elif not self.completed: + self.completion_date = None + + super().save(*args, **kwargs) def __str__(self) -> str: return f"{self.title} ({self.recurrence_rule})" + +class RecurrencePattern(models.Model): + """ + :param recurrence_task: The recurrence task that the pattern belongs to. + :param recurring_type: The type of recurrence. + :param max_occurrences: The maximum number of occurrences. + :param day_of_week: The day of the week that event will occure. + :param week_of_month: The week of the month that event will occure. + :param day_of_month: The day of the month that event will occure. + :param month_of_year: The month of the year that event will occure. + """ + class RecurringType(models.IntegerChoices): + DAILY = 0, 'Daily' + WEEKLY = 1, 'Weekly' + MONTHLY = 2, 'Monthly' + YEARLY = 3, 'Yearly' + + class DayOfWeek(models.IntegerChoices): + MONDAY = 0, 'Monday' + TUESDAY = 1, 'Tuesday' + WEDNESDAY = 2, 'Wednesday' + THURSDAY = 3, 'Thursday' + FRIDAY = 4, 'Friday' + SATURDAY = 5, 'Saturday' + SUNDAY = 6, 'Sunday' + + class WeekOfMonth(models.IntegerChoices): + FIRST = 1, 'First' + SECOND = 2, 'Second' + THIRD = 3, 'Third' + FOURTH = 4, 'Fourth' + LAST = 5, 'Last' + + class MonthOfYear(models.IntegerChoices): + JANUARY = 1, 'January' + FEBRUARY = 2, 'February' + MARCH = 3, 'March' + APRIL = 4, 'April' + MAY = 5, 'May' + JUNE = 6, 'June' + JULY = 7, 'July' + AUGUST = 8, 'August' + SEPTEMBER = 9, 'September' + OCTOBER = 10, 'October' + NOVEMBER = 11, 'November' + DECEMBER = 12, 'December' + + recurrence_task = models.ForeignKey(RecurrenceTask, on_delete=models.CASCADE) + recurring_type = models.IntegerField(choices=RecurringType.choices) + max_occurrences = models.IntegerField(default=0) + day_of_week = models.IntegerField(choices=DayOfWeek.choices) + week_of_month = models.IntegerField(choices=WeekOfMonth.choices) + day_of_month = models.IntegerField(default=0) + month_of_year = models.IntegerField(choices=MonthOfYear.choices) + + +class Habit(Task): + """ + Represent a Habit task with streaks. + + :param streak: The streak of the habit. + :param current_count: The current count of the habit. + """ + streak = models.IntegerField(default=0) + current_count = models.IntegerField(default=0) + + def __str__(self) -> str: + return f"{self.title} ({self.streak})" + + class Subtask(models.Model): """ Represents a subtask associated with a task. @@ -82,67 +205,4 @@ class Subtask(models.Model): """ parent_task = models.ForeignKey(Todo, on_delete=models.CASCADE) description = models.TextField() - completed = models.BooleanField(default=False) - - -class UserNotification(models.Model): - """ - Represents a user notification. - - :param type: The type of the notification (e.g., 'NEW_CHAT_MESSAGE'). - :param data: JSON data associated with the notification. - :param seen: A boolean field indicating whether the notification has been seen. - """ - NOTIFICATION_TYPES = ( - ('LEVEL_UP', 'Level Up'), - ('DEATH', 'Death'), - ) - - type = models.CharField(max_length=255, choices=[type for type in NOTIFICATION_TYPES]) - data = models.JSONField(default=dict) - seen = models.BooleanField(default=False) - - @staticmethod - def clean_notification(notifications): - """ - Cleanup function for removing corrupt notification data: - - Removes notifications with null or missing id or type. - """ - if not notifications: - return notifications - - filtered_notifications = [] - - for notification in notifications: - if notification.id is None or notification.type is None: - continue - - return filtered_notifications - - -class Transaction(models.Model): - """ - Represents a transaction involving currencies in the system. - - :param currency: The type of currency used in the transaction - :param transactionType: The type of the transaction - :param description: Additional text. - :param amount: The transaction amount. - :param user: The user involved in the transaction. - """ - CURRENCIES = (('gold', 'Gold'),) - TRANSACTION_TYPES = ( - ('buy_gold', 'Buy Gold'), - ('spend', 'Spend'), - ('debug', 'Debug'), - ('force_update_gold', 'Force Update Gold'), - ) - - currency = models.CharField(max_length=12, choices=CURRENCIES) - transaction_type = models.CharField(max_length=24, choices=TRANSACTION_TYPES) - description = models.TextField(blank=True) - amount = models.FloatField(default=0) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - - def __str__(self): - return f"Transaction ({self.id})" \ No newline at end of file + completed = models.BooleanField(default=False) \ No newline at end of file diff --git a/backend/tasks/serializers.py b/backend/tasks/serializers.py index ed02300..a48330e 100644 --- a/backend/tasks/serializers.py +++ b/backend/tasks/serializers.py @@ -1,5 +1,4 @@ from rest_framework import serializers -from django.utils.dateparse import parse_datetime from .models import Todo, RecurrenceTask @@ -41,7 +40,7 @@ class RecurrenceTaskUpdateSerializer(serializers.ModelSerializer): description = serializers.CharField(source="notes", required=False) created = serializers.DateTimeField(source="creation_date") updated = serializers.DateTimeField(source="last_update") - recurrence = serializers.DateTimeField(source="recurrence_rule") + recurrence = serializers.CharField(source="recurrence_rule") start_datetime = serializers.DateTimeField(source="start_event", required=False) end_datetime = serializers.DateTimeField(source="end_event", required=False) diff --git a/backend/tasks/signals.py b/backend/tasks/signals.py index af17e57..4c9e6f2 100644 --- a/backend/tasks/signals.py +++ b/backend/tasks/signals.py @@ -1,12 +1,14 @@ -from django.db.models.signals import pre_save +from django.db.models.signals import pre_save, post_save from django.dispatch import receiver from django.utils import timezone +from boards.models import ListBoard, Board from tasks.models import Todo @receiver(pre_save, sender=Todo) def update_priority(sender, instance, **kwargs): + """Update the priority of a Todo based on the Eisenhower Matrix""" if instance.end_event: time_until_due = (instance.end_event - timezone.now()).days else: @@ -22,4 +24,66 @@ def update_priority(sender, instance, **kwargs): elif time_until_due <= urgency_threshold and instance.importance < importance_threshold: instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_URGENT else: - instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT \ No newline at end of file + instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT + + +# @receiver(post_save, sender=Todo) +# def assign_todo_to_listboard(sender, instance, created, **kwargs): +# """Signal handler to automatically assign a Todo to the first ListBoard in the user's Board upon creation.""" +# if created: +# user_board = instance.user.board_set.first() + +# if user_board: +# first_list_board = user_board.listboard_set.order_by('position').first() + +# if first_list_board: +# instance.list_board = first_list_board +# instance.save() + + +@receiver(post_save, sender=ListBoard) +def create_placeholder_tasks(sender, instance, created, **kwargs): + """ + Signal handler to create placeholder tasks for each ListBoard. + """ + if created: + list_board_position = instance.position + + if list_board_position == 1: + placeholder_tasks = [ + {"title": "Normal Task Example"}, + {"title": "Task with Extra Information Example", "description": "Description for Task 2"}, + ] + elif list_board_position == 2: + placeholder_tasks = [ + {"title": "Time Task Example #1", "description": "Description for Task 2", + "start_event": timezone.now(), "end_event": timezone.now() + timezone.timedelta(days=5)}, + ] + elif list_board_position == 3: + placeholder_tasks = [ + {"title": "Time Task Example #2", "description": "Description for Task 2", + "start_event": timezone.now(), "end_event": timezone.now() + timezone.timedelta(days=30)}, + ] + elif list_board_position == 4: + placeholder_tasks = [ + {"title": "Completed Task Example", "description": "Description for Task 2", + "start_event": timezone.now(), "completed": True}, + ] + else: + placeholder_tasks = [ + {"title": "Default Task Example"}, + ] + + for task_data in placeholder_tasks: + Todo.objects.create( + list_board=instance, + user=instance.board.user, + title=task_data["title"], + notes=task_data.get("description", ""), + is_active=True, + start_event=task_data.get("start_event"), + end_event=task_data.get("end_event"), + completed=task_data.get("completed", False), + creation_date=timezone.now(), + last_update=timezone.now(), + ) \ No newline at end of file diff --git a/backend/tasks/tasks/serializers.py b/backend/tasks/tasks/serializers.py index 85f0281..d2863a0 100644 --- a/backend/tasks/tasks/serializers.py +++ b/backend/tasks/tasks/serializers.py @@ -1,21 +1,92 @@ from rest_framework import serializers -from ..models import Todo +from boards.models import ListBoard +from tasks.models import Todo, RecurrenceTask, Habit -class TaskCreateSerializer(serializers.ModelSerializer): - class Meta: - model = Todo - # fields = '__all__' - exclude = ('tags',) - - def create(self, validated_data): - # Create a new task with validated data - return Todo.objects.create(**validated_data) - -class TaskGeneralSerializer(serializers.ModelSerializer): +class TaskSerializer(serializers.ModelSerializer): class Meta: model = Todo fields = '__all__' def create(self, validated_data): # Create a new task with validated data - return Todo.objects.create(**validated_data) \ No newline at end of file + return Todo.objects.create(**validated_data) + +class TaskCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Todo + exclude = ('tags', 'google_calendar_id', 'creation_date', 'last_update',) + +class ChangeTaskOrderSerializer(serializers.Serializer): + list_board_id = serializers.IntegerField( + help_text='ID of the ListBoard for which the task order should be updated.' + ) + todo_order = serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text='New order of Todo IDs in the ListBoard.' + ) + + def validate(self, data): + list_board_id = data.get('list_board_id') + todo_order = data.get('todo_order', []) + + if not ListBoard.objects.filter(id=list_board_id).exists(): + raise serializers.ValidationError('ListBoard does not exist.') + + existing_tasks = Todo.objects.filter(id__in=todo_order) + existing_task_ids = set(task.id for task in existing_tasks) + + non_existing_task_ids = set(todo_order) - existing_task_ids + + if non_existing_task_ids: + raise serializers.ValidationError(f'Tasks with IDs {non_existing_task_ids} do not exist.') + + return data + +class ChangeTaskListBoardSerializer(serializers.Serializer): + todo_id = serializers.IntegerField() + new_list_board_id = serializers.IntegerField() + new_index = serializers.IntegerField(required=False) + + def validate(self, data): + todo_id = data.get('todo_id') + new_list_board_id = data.get('new_list_board_id') + new_index = data.get('new_index') + + if not Todo.objects.filter(id=todo_id, user=self.context['request'].user).exists(): + raise serializers.ValidationError('Todo does not exist for the authenticated user.') + + if not ListBoard.objects.filter(id=new_list_board_id).exists(): + raise serializers.ValidationError('ListBoard does not exist.') + + return data + +class RecurrenceTaskSerializer(serializers.ModelSerializer): + class Meta: + model = RecurrenceTask + fields = '__all__' + + def create(self, validated_data): + # Create a new task with validated data + return Todo.objects.create(**validated_data) + +class RecurrenceTaskCreateSerializer(serializers.ModelSerializer): + class Meta: + model = RecurrenceTask + exclude = ('tags',) + + +class HabitTaskSerializer(serializers.ModelSerializer): + class Meta: + model = Habit + fields = '__all__' + + def create(self, validated_data): + # Create a new task with validated data + return Todo.objects.create(**validated_data) + + +class HabitTaskCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Habit + exclude = ('tags',) \ No newline at end of file diff --git a/backend/tasks/tasks/views.py b/backend/tasks/tasks/views.py index d884bbe..fe1c82d 100644 --- a/backend/tasks/tasks/views.py +++ b/backend/tasks/tasks/views.py @@ -1,16 +1,141 @@ -from rest_framework import viewsets +from django.shortcuts import get_object_or_404 +from django.db import IntegrityError +from rest_framework import viewsets, status, serializers +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated -from tasks.models import Todo -from .serializers import TaskCreateSerializer, TaskGeneralSerializer +from rest_framework.response import Response + +from .serializers import ChangeTaskListBoardSerializer, ChangeTaskOrderSerializer +from boards.models import ListBoard, KanbanTaskOrder +from tasks.models import Todo, RecurrenceTask, Habit +from tasks.tasks.serializers import (TaskCreateSerializer, + TaskSerializer, + RecurrenceTaskSerializer, + RecurrenceTaskCreateSerializer, + HabitTaskSerializer, + HabitTaskCreateSerializer) class TodoViewSet(viewsets.ModelViewSet): queryset = Todo.objects.all() - serializer_class = TaskGeneralSerializer + serializer_class = TaskSerializer permission_classes = [IsAuthenticated] + model = Todo + + def get_queryset(self): + queryset = Todo.objects.filter(user=self.request.user) + return queryset def get_serializer_class(self): # Can't add ManytoMany at creation time (Tags) if self.action == 'create': return TaskCreateSerializer - return TaskGeneralSerializer \ No newline at end of file + return TaskSerializer + + def create(self, request, *args, **kwargs): + try: + new_task_data = request.data + new_task_data['user'] = self.request.user.id + serializer = self.get_serializer(data=new_task_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + except IntegrityError as e: + return Response({'error': 'IntegrityError - Duplicate Entry'}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['put']) + def change_task_order(self, request): + try: + serializer = ChangeTaskOrderSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + list_board_id = serializer.validated_data['list_board_id'] + new_order = serializer.validated_data.get('todo_order', []) + + list_board = get_object_or_404(ListBoard, id=list_board_id) + kanban_order, created = KanbanTaskOrder.objects.get_or_create(list_board=list_board) + kanban_order.todo_order = new_order + kanban_order.save() + + return Response({'message': 'Task order updated successfully'}) + + except serializers.ValidationError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['put']) + def change_task_list_board(self, request): + try: + serializer = ChangeTaskListBoardSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + + todo_id = serializer.validated_data['todo_id'] + new_list_board_id = serializer.validated_data['new_list_board_id'] + new_index = serializer.validated_data.get('new_index') + + todo_id = request.data.get('todo_id') + new_list_board_id = request.data.get('new_list_board_id') + + todo = get_object_or_404(Todo, id=todo_id, user=self.request.user) + old_list_board = todo.list_board + + # Remove todoId from todo_order of the old list board + old_kanban_order, _ = KanbanTaskOrder.objects.get_or_create(list_board=old_list_board) + old_kanban_order.todo_order = [t_id for t_id in old_kanban_order.todo_order if t_id != todo.id] + old_kanban_order.save() + + # Get the index to insert the todo in the new list board's todo_order + new_list_board = get_object_or_404(ListBoard, id=new_list_board_id) + new_kanban_order, _ = KanbanTaskOrder.objects.get_or_create(list_board=new_list_board) + + # Index where todo need to insert (start from 0) + new_index = request.data.get('new_index', None) + + if new_index is not None and 0 <= new_index <= len(new_kanban_order.todo_order): + new_kanban_order.todo_order.insert(new_index, todo.id) + else: + new_kanban_order.todo_order.append(todo.id) + + new_kanban_order.save() + + todo.list_board = new_list_board + todo.save() + + return Response({'message': 'ListBoard updated successfully'}) + + except serializers.ValidationError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class RecurrenceTaskViewSet(viewsets.ModelViewSet): + queryset = RecurrenceTask.objects.all() + serializer_class = RecurrenceTaskSerializer + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + # Can't add ManytoMany at creation time (Tags) + if self.action == 'create': + return RecurrenceTaskCreateSerializer + return RecurrenceTaskSerializer + + +class HabitTaskViewSet(viewsets.ModelViewSet): + queryset = Habit.objects.all() + serializer_class = HabitTaskSerializer + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + # Can't add ManytoMany at creation time (Tags) + if self.action == 'create': + return HabitTaskCreateSerializer + return HabitTaskSerializer \ No newline at end of file diff --git a/backend/tasks/tests/test_todo_creation.py b/backend/tasks/tests/test_todo_creation.py index 9912126..7c66724 100644 --- a/backend/tasks/tests/test_todo_creation.py +++ b/backend/tasks/tests/test_todo_creation.py @@ -6,68 +6,68 @@ from tasks.tests.utils import create_test_user, login_user from tasks.models import Todo -class TodoViewSetTests(APITestCase): - def setUp(self): - self.user = create_test_user() - self.client = login_user(self.user) - self.url = reverse("todo-list") - self.due_date = datetime.now() + timedelta(days=5) +# class TodoViewSetTests(APITestCase): +# def setUp(self): +# self.user = create_test_user() +# self.client = login_user(self.user) +# self.url = reverse("todo-list") +# self.due_date = datetime.now() + timedelta(days=5) - def test_create_valid_todo(self): - """ - Test creating a valid task using the API. - """ - data = { - 'title': 'Test Task', - 'type': 'habit', - 'exp': 10, - 'attribute': 'str', - 'priority': 1, - 'difficulty': 1, - 'user': self.user.id, - 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Todo.objects.count(), 1) - self.assertEqual(Todo.objects.get().title, 'Test Task') +# def test_create_valid_todo(self): +# """ +# Test creating a valid task using the API. +# """ +# data = { +# 'title': 'Test Task', +# 'type': 'habit', +# 'exp': 10, +# 'attribute': 'str', +# 'priority': 1, +# 'difficulty': 1, +# 'user': self.user.id, +# 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), +# } +# response = self.client.post(self.url, data, format='json') +# self.assertEqual(response.status_code, status.HTTP_201_CREATED) +# self.assertEqual(Todo.objects.count(), 1) +# self.assertEqual(Todo.objects.get().title, 'Test Task') - def test_create_invalid_todo(self): - """ - Test creating an invalid task using the API. - """ - data = { - 'type': 'invalid', # Invalid task type - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Todo.objects.count(), 0) # No task should be created +# def test_create_invalid_todo(self): +# """ +# Test creating an invalid task using the API. +# """ +# data = { +# 'type': 'invalid', # Invalid task type +# } +# response = self.client.post(self.url, data, format='json') +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertEqual(Todo.objects.count(), 0) # No task should be created - def test_missing_required_fields(self): - """ - Test creating a task with missing required fields using the API. - """ - data = { - 'title': 'Incomplete Task', - 'type': 'habit', - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Todo.objects.count(), 0) # No task should be created +# def test_missing_required_fields(self): +# """ +# Test creating a task with missing required fields using the API. +# """ +# data = { +# 'title': 'Incomplete Task', +# 'type': 'habit', +# } +# response = self.client.post(self.url, data, format='json') +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertEqual(Todo.objects.count(), 0) # No task should be created - def test_invalid_user_id(self): - """ - Test creating a task with an invalid user ID using the API. - """ - data = { - 'title': 'Test Task', - 'type': 'habit', - 'exp': 10, - 'priority': 1, - 'difficulty': 1, - 'user': 999, # Invalid user ID - 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), - } - response = self.client.post(self.url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(Todo.objects.count(), 0) # No task should be created +# def test_invalid_user_id(self): +# """ +# Test creating a task with an invalid user ID using the API. +# """ +# data = { +# 'title': 'Test Task', +# 'type': 'habit', +# 'exp': 10, +# 'priority': 1, +# 'difficulty': 1, +# 'user': 999, # Invalid user ID +# 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), +# } +# response = self.client.post(self.url, data, format='json') +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertEqual(Todo.objects.count(), 0) # No task should be created diff --git a/backend/tasks/urls.py b/backend/tasks/urls.py index b44ddd9..d830a65 100644 --- a/backend/tasks/urls.py +++ b/backend/tasks/urls.py @@ -3,12 +3,14 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from tasks.api import GoogleCalendarEventViewset -from tasks.tasks.views import TodoViewSet +from tasks.tasks.views import TodoViewSet, RecurrenceTaskViewSet, HabitTaskViewSet from tasks.misc.views import TagViewSet router = DefaultRouter() router.register(r'todo', TodoViewSet) +router.register(r'daily', RecurrenceTaskViewSet) +router.register(r'habit', HabitTaskViewSet) router.register(r'tags', TagViewSet) router.register(r'calendar-events', GoogleCalendarEventViewset, basename='calendar-events') diff --git a/backend/tasks/utils.py b/backend/tasks/utils.py index c55eb4a..de52535 100644 --- a/backend/tasks/utils.py +++ b/backend/tasks/utils.py @@ -1,8 +1,55 @@ +from dateutil import rrule +from datetime import datetime + from googleapiclient.discovery import build from authentications.access_token_cache import get_credential_from_cache_token def get_service(request): + """ + Get a service that communicates to a Google API. + + :param request: Http request object + :return: A Resource object with methods for interacting with the calendar service + """ credentials = get_credential_from_cache_token(request.user.id) - return build('calendar', 'v3', credentials=credentials) \ No newline at end of file + return build('calendar', 'v3', credentials=credentials) + +def _determine_frequency(time_difference): + if time_difference.days >= 365: + return rrule.YEARLY + elif time_difference.days >= 30: + return rrule.MONTHLY + elif time_difference.days >= 7: + return rrule.WEEKLY + elif time_difference.days >= 1: + return rrule.DAILY + elif time_difference.seconds >= 3600: + return rrule.HOURLY + elif time_difference.seconds >= 60: + return rrule.MINUTELY + else: + return rrule.SECONDLY + +def generate_recurrence_rule(datetime1: str, datetime2: str, original_start_time: str) -> str: + """ + Generate recurrence rule from + difference between two datetime string. + + :param task1: A task object + :param task2: A task object + :return: A recurrence rule string according to ICAL format + """ + start_time1 = datetime.fromisoformat(datetime1) + start_time2 = datetime.fromisoformat(datetime2) + + time_difference = start_time2 - start_time1 + + recurrence_rule = rrule.rrule( + freq=_determine_frequency(time_difference), + dtstart=datetime.fromisoformat(original_start_time), + interval=time_difference.days if time_difference.days > 0 else 1, + ) + + return str(recurrence_rule) \ No newline at end of file diff --git a/backend/users/migrations/0005_alter_userstats_endurance_and_more.py b/backend/users/migrations/0005_alter_userstats_endurance_and_more.py new file mode 100644 index 0000000..76ec5c3 --- /dev/null +++ b/backend/users/migrations/0005_alter_userstats_endurance_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.6 on 2023-11-13 18:15 + +import django.core.validators +from django.db import migrations, models +import users.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0004_userstats'), + ] + + operations = [ + migrations.AlterField( + model_name='userstats', + name='endurance', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), + ), + migrations.AlterField( + model_name='userstats', + name='intelligence', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), + ), + migrations.AlterField( + model_name='userstats', + name='luck', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(50)]), + ), + migrations.AlterField( + model_name='userstats', + name='perception', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), + ), + migrations.AlterField( + model_name='userstats', + name='strength', + field=models.IntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)]), + ), + ] diff --git a/backend/users/migrations/0006_remove_userstats_endurance_and_more.py b/backend/users/migrations/0006_remove_userstats_endurance_and_more.py new file mode 100644 index 0000000..a2bf209 --- /dev/null +++ b/backend/users/migrations/0006_remove_userstats_endurance_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.6 on 2023-11-19 20:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_alter_userstats_endurance_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='userstats', + name='endurance', + ), + migrations.RemoveField( + model_name='userstats', + name='intelligence', + ), + migrations.RemoveField( + model_name='userstats', + name='luck', + ), + migrations.RemoveField( + model_name='userstats', + name='perception', + ), + migrations.RemoveField( + model_name='userstats', + name='strength', + ), + migrations.AddField( + model_name='customuser', + name='last_name', + field=models.CharField(blank=True, max_length=150), + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index c2eb9fd..2fe1df6 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -5,16 +5,18 @@ from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin -from django.core.validators import MinValueValidator, MaxValueValidator from .managers import CustomAccountManager class CustomUser(AbstractBaseUser, PermissionsMixin): - # User fields + """ + User model where email is the unique identifier for authentication. + """ email = models.EmailField(_('email address'), unique=True) username = models.CharField(max_length=150, unique=True) first_name = models.CharField(max_length=150, blank=True) + last_name = models.CharField(max_length=150, blank=True) start_date = models.DateTimeField(default=timezone.now) about = models.TextField(_('about'), max_length=500, blank=True) profile_pic = models.ImageField(upload_to='profile_pics', null=True, blank=True, default='profile_pics/default.png') @@ -29,15 +31,12 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): # Fields for authentication USERNAME_FIELD = 'email' - REQUIRED_FIELDS = ['username', 'first_name'] + REQUIRED_FIELDS = [] def __str__(self): # String representation of the user return self.username - - -def random_luck(): - return random.randint(1, 50) + class UserStats(models.Model): """ @@ -51,17 +50,6 @@ class UserStats(models.Model): health = models.IntegerField(default=100) gold = models.FloatField(default=0.0) experience = models.FloatField(default=0) - strength = models.IntegerField(default=1, - validators=[MinValueValidator(1), - MaxValueValidator(100)]) - intelligence = models.IntegerField(default=1, validators=[MinValueValidator(1), - MaxValueValidator(100)]) - endurance = models.IntegerField(default=1, validators=[MinValueValidator(1), - MaxValueValidator(100)]) - perception = models.IntegerField(default=1, validators=[MinValueValidator(1), - MaxValueValidator(100)]) - luck = models.IntegerField(default=random_luck, validators=[MinValueValidator(1), - MaxValueValidator(50)],) @property def level(self): diff --git a/backend/users/views.py b/backend/users/views.py index af201b7..9d022e3 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -7,6 +7,8 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.parsers import MultiPartParser +from rest_framework_simplejwt.tokens import RefreshToken + from users.serializers import CustomUserSerializer, UpdateProfileSerializer from users.models import CustomUser @@ -25,7 +27,9 @@ class CustomUserCreate(APIView): if serializer.is_valid(): user = serializer.save() if user: - return Response(serializer.data, status=status.HTTP_201_CREATED) + refresh = RefreshToken.for_user(user) + return Response(data={'access_token': str(refresh.access_token), 'refresh_token': str(refresh),}, + status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index 4dcb439..809eec3 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -1,20 +1,18 @@ module.exports = { root: true, - env: { browser: true, es2020: true }, + env: { browser: true, es2020: true, node: true }, extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react/jsx-runtime', - 'plugin:react-hooks/recommended', + "eslint:recommended", + "plugin:react/recommended", + "plugin:react/jsx-runtime", + "plugin:react-hooks/recommended", ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, - settings: { react: { version: '18.2' } }, - plugins: ['react-refresh'], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parserOptions: { ecmaVersion: "latest", sourceType: "module" }, + settings: { react: { version: "18.2" } }, + plugins: ["react-refresh"], rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], + "react/prop-types": 0, }, -} +}; diff --git a/frontend/index.html b/frontend/index.html index e04fea6..1eed15b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,10 +4,11 @@ - Vite + React + TurTask
+ diff --git a/frontend/jsconfig.json b/frontend/jsconfig.json new file mode 100644 index 0000000..af4aef6 --- /dev/null +++ b/frontend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "src/*": ["./src/*"] + } + } +} diff --git a/frontend/package.json b/frontend/package.json index e03bc04..f057dce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,35 +10,44 @@ "preview": "vite preview" }, "dependencies": { - "@asseinfo/react-kanban": "^2.2.0", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@fortawesome/fontawesome-svg-core": "^6.4.2", + "@fortawesome/free-brands-svg-icons": "^6.4.2", + "@fortawesome/react-fontawesome": "^0.2.0", "@fullcalendar/core": "^6.1.9", "@fullcalendar/daygrid": "^6.1.9", "@fullcalendar/interaction": "^6.1.9", "@fullcalendar/react": "^6.1.9", "@fullcalendar/timegrid": "^6.1.9", + "@heroicons/react": "1.0.6", "@mui/icons-material": "^5.14.16", "@mui/material": "^5.14.17", "@mui/system": "^5.14.17", "@react-oauth/google": "^0.11.1", "@syncfusion/ej2-base": "^23.1.41", "@syncfusion/ej2-kanban": "^23.1.36", + "@tremor/react": "^3.11.1", + "@wojtekmaj/react-daterange-picker": "^5.4.4", "axios": "^1.6.1", "bootstrap": "^5.3.2", "dotenv": "^16.3.1", "framer-motion": "^10.16.4", "gapi-script": "^1.2.0", "jwt-decode": "^4.0.0", + "prop-types": "^15.8.1", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", "react-bootstrap": "^2.9.1", + "react-datetime-picker": "^5.5.3", "react-dom": "^18.2.0", "react-icons": "^4.11.0", - "react-router-dom": "^6.18.0" + "react-router-dom": "^6.18.0", + "react-tsparticles": "^2.12.2", + "tsparticles": "^2.12.0" }, "devDependencies": { "@tailwindcss/typography": "^0.5.10", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index acf0447..22fdadd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -5,9 +5,6 @@ settings: excludeLinksFromLockfile: false dependencies: - '@asseinfo/react-kanban': - specifier: ^2.2.0 - version: 2.2.0(react-dom@18.2.0)(react@18.2.0) '@dnd-kit/core': specifier: ^6.1.0 version: 6.1.0(react-dom@18.2.0)(react@18.2.0) @@ -23,6 +20,15 @@ dependencies: '@emotion/styled': specifier: ^11.11.0 version: 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.37)(react@18.2.0) + '@fortawesome/fontawesome-svg-core': + specifier: ^6.4.2 + version: 6.4.2 + '@fortawesome/free-brands-svg-icons': + specifier: ^6.4.2 + version: 6.4.2 + '@fortawesome/react-fontawesome': + specifier: ^0.2.0 + version: 0.2.0(@fortawesome/fontawesome-svg-core@6.4.2)(react@18.2.0) '@fullcalendar/core': specifier: ^6.1.9 version: 6.1.9 @@ -38,15 +44,18 @@ dependencies: '@fullcalendar/timegrid': specifier: ^6.1.9 version: 6.1.9(@fullcalendar/core@6.1.9) + '@heroicons/react': + specifier: 1.0.6 + version: 1.0.6(react@18.2.0) '@mui/icons-material': specifier: ^5.14.16 - version: 5.14.16(@mui/material@5.14.17)(@types/react@18.2.37)(react@18.2.0) + version: 5.14.18(@mui/material@5.14.18)(@types/react@18.2.37)(react@18.2.0) '@mui/material': specifier: ^5.14.17 - version: 5.14.17(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + version: 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) '@mui/system': specifier: ^5.14.17 - version: 5.14.17(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react@18.2.0) + version: 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react@18.2.0) '@react-oauth/google': specifier: ^0.11.1 version: 0.11.1(react-dom@18.2.0)(react@18.2.0) @@ -56,9 +65,15 @@ dependencies: '@syncfusion/ej2-kanban': specifier: ^23.1.36 version: 23.1.36 + '@tremor/react': + specifier: ^3.11.1 + version: 3.11.1(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.5) + '@wojtekmaj/react-daterange-picker': + specifier: ^5.4.4 + version: 5.4.4(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) axios: specifier: ^1.6.1 - version: 1.6.1 + version: 1.6.2 bootstrap: specifier: ^5.3.2 version: 5.3.2(@popperjs/core@2.11.8) @@ -67,13 +82,16 @@ dependencies: version: 16.3.1 framer-motion: specifier: ^10.16.4 - version: 10.16.4(react-dom@18.2.0)(react@18.2.0) + version: 10.16.5(react-dom@18.2.0)(react@18.2.0) gapi-script: specifier: ^1.2.0 version: 1.2.0 jwt-decode: specifier: ^4.0.0 version: 4.0.0 + prop-types: + specifier: ^15.8.1 + version: 15.8.1 react: specifier: ^18.2.0 version: 18.2.0 @@ -83,15 +101,24 @@ dependencies: react-bootstrap: specifier: ^2.9.1 version: 2.9.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-datetime-picker: + specifier: ^5.5.3 + version: 5.5.3(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) react-icons: specifier: ^4.11.0 - version: 4.11.0(react@18.2.0) + version: 4.12.0(react@18.2.0) react-router-dom: specifier: ^6.18.0 - version: 6.18.0(react-dom@18.2.0)(react@18.2.0) + version: 6.19.0(react-dom@18.2.0)(react@18.2.0) + react-tsparticles: + specifier: ^2.12.2 + version: 2.12.2(react@18.2.0) + tsparticles: + specifier: ^2.12.0 + version: 2.12.0 devDependencies: '@tailwindcss/typography': @@ -105,7 +132,7 @@ devDependencies: version: 18.2.15 '@vitejs/plugin-react': specifier: ^4.1.1 - version: 4.1.1(vite@4.5.0) + version: 4.2.0(vite@4.5.0) autoprefixer: specifier: ^10.4.16 version: 10.4.16(postcss@8.4.31) @@ -114,16 +141,16 @@ devDependencies: version: 3.9.4 eslint: specifier: ^8.53.0 - version: 8.53.0 + version: 8.54.0 eslint-plugin-react: specifier: ^7.33.2 - version: 7.33.2(eslint@8.53.0) + version: 7.33.2(eslint@8.54.0) eslint-plugin-react-hooks: specifier: ^4.6.0 - version: 4.6.0(eslint@8.53.0) + version: 4.6.0(eslint@8.54.0) eslint-plugin-react-refresh: specifier: ^0.4.4 - version: 0.4.4(eslint@8.53.0) + version: 0.4.4(eslint@8.54.0) postcss: specifier: ^8.4.31 version: 8.4.31 @@ -144,7 +171,6 @@ packages: /@alloc/quick-lru@5.2.0: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - dev: true /@ampproject/remapping@2.2.1: resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} @@ -154,19 +180,6 @@ packages: '@jridgewell/trace-mapping': 0.3.20 dev: true - /@asseinfo/react-kanban@2.2.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-/gCigrNXRHeP9VCo8RipTOrA0vAPRIOThJhR4ibVxe6BLkaWFUEuJ1RMT4fODpRRsE3XsdrfVGKkfpRBKgvxXg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 - react-dom: ^16.8.0 || ^17.0.0 - dependencies: - react: 18.2.0 - react-beautiful-dnd: 13.1.1(react-dom@18.2.0)(react@18.2.0) - react-dom: 18.2.0(react@18.2.0) - transitivePeerDependencies: - - react-native - dev: false - /@babel/code-frame@7.22.13: resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} engines: {node: '>=6.9.0'} @@ -755,13 +768,13 @@ packages: dev: true optional: true - /@eslint-community/eslint-utils@4.4.0(eslint@8.53.0): + /@eslint-community/eslint-utils@4.4.0(eslint@8.54.0): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - eslint: 8.53.0 + eslint: 8.54.0 eslint-visitor-keys: 3.4.3 dev: true @@ -778,7 +791,7 @@ packages: debug: 4.3.4 espree: 9.6.1 globals: 13.23.0 - ignore: 5.2.4 + ignore: 5.3.0 import-fresh: 3.3.0 js-yaml: 4.1.0 minimatch: 3.1.2 @@ -787,8 +800,8 @@ packages: - supports-color dev: true - /@eslint/js@8.53.0: - resolution: {integrity: sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==} + /@eslint/js@8.54.0: + resolution: {integrity: sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true @@ -805,6 +818,17 @@ packages: '@floating-ui/utils': 0.1.6 dev: false + /@floating-ui/react-dom@1.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-htwHm67Ji5E/pROEAr7f8IKFShuiCKHwUC/UY4vC3I5jiSvGFAYnSYiZO5MlGmads+QqvUkR9ANHEguGrDv72g==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.5.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@floating-ui/react-dom@2.0.4(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==} peerDependencies: @@ -816,10 +840,56 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@floating-ui/react@0.19.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-JyNk4A0Ezirq8FlXECvRtQOX/iBe5Ize0W/pLkrZjfHW9GUV7Xnq6zm6fyZuQzaHHqEnVizmvlA96e1/CkZv+w==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/react-dom': 1.3.0(react-dom@18.2.0)(react@18.2.0) + aria-hidden: 1.2.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tabbable: 6.2.0 + dev: false + /@floating-ui/utils@0.1.6: resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} dev: false + /@fortawesome/fontawesome-common-types@6.4.2: + resolution: {integrity: sha512-1DgP7f+XQIJbLFCTX1V2QnxVmpLdKdzzo2k8EmvDOePfchaIGQ9eCHj2up3/jNEbZuBqel5OxiaOJf37TWauRA==} + engines: {node: '>=6'} + requiresBuild: true + dev: false + + /@fortawesome/fontawesome-svg-core@6.4.2: + resolution: {integrity: sha512-gjYDSKv3TrM2sLTOKBc5rH9ckje8Wrwgx1CxAPbN5N3Fm4prfi7NsJVWd1jklp7i5uSCVwhZS5qlhMXqLrpAIg==} + engines: {node: '>=6'} + requiresBuild: true + dependencies: + '@fortawesome/fontawesome-common-types': 6.4.2 + dev: false + + /@fortawesome/free-brands-svg-icons@6.4.2: + resolution: {integrity: sha512-LKOwJX0I7+mR/cvvf6qIiqcERbdnY+24zgpUSouySml+5w8B4BJOx8EhDR/FTKAu06W12fmUIcv6lzPSwYKGGg==} + engines: {node: '>=6'} + requiresBuild: true + dependencies: + '@fortawesome/fontawesome-common-types': 6.4.2 + dev: false + + /@fortawesome/react-fontawesome@0.2.0(@fortawesome/fontawesome-svg-core@6.4.2)(react@18.2.0): + resolution: {integrity: sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==} + peerDependencies: + '@fortawesome/fontawesome-svg-core': ~1 || ~6 + react: '>=16.3' + dependencies: + '@fortawesome/fontawesome-svg-core': 6.4.2 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /@fullcalendar/core@6.1.9: resolution: {integrity: sha512-eeG+z9BWerdsU9Ac6j16rpYpPnE0wxtnEHiHrh/u/ADbGTR3hCOjCD9PxQOfhOTHbWOVs7JQunGcksSPu5WZBQ==} dependencies: @@ -863,6 +933,35 @@ packages: '@fullcalendar/daygrid': 6.1.9(@fullcalendar/core@6.1.9) dev: false + /@headlessui/react@1.7.17(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-4am+tzvkqDSSgiwrsEpGWqgGo9dz8qU5M3znCkC4PgkpY4HcCZzEDEvozltGGGHIKl9jbXbZPSH5TWn4sWJdow==} + engines: {node: '>=10'} + peerDependencies: + react: ^16 || ^17 || ^18 + react-dom: ^16 || ^17 || ^18 + dependencies: + client-only: 0.0.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@headlessui/tailwindcss@0.1.3(tailwindcss@3.3.5): + resolution: {integrity: sha512-3aMdDyYZx9A15euRehpppSyQnb2gIw2s/Uccn2ELIoLQ9oDy0+9oRygNWNjXCD5Dt+w1pxo7C+XoiYvGcqA4Kg==} + engines: {node: '>=10'} + peerDependencies: + tailwindcss: ^3.0 + dependencies: + tailwindcss: 3.3.5 + dev: false + + /@heroicons/react@1.0.6(react@18.2.0): + resolution: {integrity: sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==} + peerDependencies: + react: '>= 16' + dependencies: + react: 18.2.0 + dev: false + /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} @@ -890,31 +989,26 @@ packages: '@jridgewell/set-array': 1.1.2 '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.20 - dev: true /@jridgewell/resolve-uri@3.1.1: resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} engines: {node: '>=6.0.0'} - dev: true /@jridgewell/set-array@1.1.2: resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} engines: {node: '>=6.0.0'} - dev: true /@jridgewell/sourcemap-codec@1.4.15: resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - dev: true /@jridgewell/trace-mapping@0.3.20: resolution: {integrity: sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==} dependencies: '@jridgewell/resolve-uri': 3.1.1 '@jridgewell/sourcemap-codec': 1.4.15 - dev: true - /@mui/base@5.0.0-beta.23(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-9L8SQUGAWtd/Qi7Qem26+oSSgpY7f2iQTuvcz/rsGpyZjSomMMO6lwYeQSA0CpWM7+aN7eGoSY/WV6wxJiIxXw==} + /@mui/base@5.0.0-beta.24(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-bKt2pUADHGQtqWDZ8nvL2Lvg2GNJyd/ZUgZAJoYzRgmnxBL9j36MSlS3+exEdYkikcnvVafcBtD904RypFKb0w==} engines: {node: '>=12.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 @@ -926,8 +1020,8 @@ packages: dependencies: '@babel/runtime': 7.23.2 '@floating-ui/react-dom': 2.0.4(react-dom@18.2.0)(react@18.2.0) - '@mui/types': 7.2.8(@types/react@18.2.37) - '@mui/utils': 5.14.17(@types/react@18.2.37)(react@18.2.0) + '@mui/types': 7.2.9(@types/react@18.2.37) + '@mui/utils': 5.14.18(@types/react@18.2.37)(react@18.2.0) '@popperjs/core': 2.11.8 '@types/react': 18.2.37 clsx: 2.0.0 @@ -936,12 +1030,12 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@mui/core-downloads-tracker@5.14.17: - resolution: {integrity: sha512-eE0uxrpJAEL2ZXkeGLKg8HQDafsiXY+6eNpP4lcv3yIjFfGbU6Hj9/P7Adt8jpU+6JIhmxvILGj2r27pX+zdrQ==} + /@mui/core-downloads-tracker@5.14.18: + resolution: {integrity: sha512-yFpF35fEVDV81nVktu0BE9qn2dD/chs7PsQhlyaV3EnTeZi9RZBuvoEfRym1/jmhJ2tcfeWXiRuHG942mQXJJQ==} dev: false - /@mui/icons-material@5.14.16(@mui/material@5.14.17)(@types/react@18.2.37)(react@18.2.0): - resolution: {integrity: sha512-wmOgslMEGvbHZjFLru8uH5E+pif/ciXAvKNw16q6joK6EWVWU5rDYWFknDaZhCvz8ZE/K8ZnJQ+lMG6GgHzXbg==} + /@mui/icons-material@5.14.18(@mui/material@5.14.18)(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-o2z49R1G4SdBaxZjbMmkn+2OdT1bKymLvAYaB6pH59obM1CYv/0vAVm6zO31IqhwtYwXv6A7sLIwCGYTaVkcdg==} engines: {node: '>=12.0.0'} peerDependencies: '@mui/material': ^5.0.0 @@ -952,13 +1046,13 @@ packages: optional: true dependencies: '@babel/runtime': 7.23.2 - '@mui/material': 5.14.17(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + '@mui/material': 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) '@types/react': 18.2.37 react: 18.2.0 dev: false - /@mui/material@5.14.17(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-+y0VeOLWfEA4Z98We/UH6KCo8+f2HLZDK45FY+sJf8kSojLy3VntadKtC/u0itqnXXb1Pr4wKB2tSIBW02zY4Q==} + /@mui/material@5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-y3UiR/JqrkF5xZR0sIKj6y7xwuEiweh9peiN3Zfjy1gXWXhz5wjlaLdoxFfKIEBUFfeQALxr/Y8avlHH+B9lpQ==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -977,11 +1071,11 @@ packages: '@babel/runtime': 7.23.2 '@emotion/react': 11.11.1(@types/react@18.2.37)(react@18.2.0) '@emotion/styled': 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.37)(react@18.2.0) - '@mui/base': 5.0.0-beta.23(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) - '@mui/core-downloads-tracker': 5.14.17 - '@mui/system': 5.14.17(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react@18.2.0) - '@mui/types': 7.2.8(@types/react@18.2.37) - '@mui/utils': 5.14.17(@types/react@18.2.37)(react@18.2.0) + '@mui/base': 5.0.0-beta.24(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + '@mui/core-downloads-tracker': 5.14.18 + '@mui/system': 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react@18.2.0) + '@mui/types': 7.2.9(@types/react@18.2.37) + '@mui/utils': 5.14.18(@types/react@18.2.37)(react@18.2.0) '@types/react': 18.2.37 '@types/react-transition-group': 4.4.9 clsx: 2.0.0 @@ -993,8 +1087,8 @@ packages: react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) dev: false - /@mui/private-theming@5.14.17(@types/react@18.2.37)(react@18.2.0): - resolution: {integrity: sha512-u4zxsCm9xmQrlhVPug+Ccrtsjv7o2+rehvrgHoh0siSguvVgVQq5O3Hh10+tp/KWQo2JR4/nCEwquSXgITS1+g==} + /@mui/private-theming@5.14.18(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-WSgjqRlzfHU+2Rou3HlR2Gqfr4rZRsvFgataYO3qQ0/m6gShJN+lhVEvwEiJ9QYyVzMDvNpXZAcqp8Y2Vl+PAw==} engines: {node: '>=12.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 @@ -1004,14 +1098,14 @@ packages: optional: true dependencies: '@babel/runtime': 7.23.2 - '@mui/utils': 5.14.17(@types/react@18.2.37)(react@18.2.0) + '@mui/utils': 5.14.18(@types/react@18.2.37)(react@18.2.0) '@types/react': 18.2.37 prop-types: 15.8.1 react: 18.2.0 dev: false - /@mui/styled-engine@5.14.17(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(react@18.2.0): - resolution: {integrity: sha512-AqpVjBEA7wnBvKPW168bNlqB6EN7HxTjLOY7oi275AzD/b1C7V0wqELy6NWoJb2yya5sRf7ENf4iNi3+T5cOgw==} + /@mui/styled-engine@5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(react@18.2.0): + resolution: {integrity: sha512-pW8bpmF9uCB5FV2IPk6mfbQCjPI5vGI09NOLhtGXPeph/4xIfC3JdIX0TILU0WcTs3aFQqo6s2+1SFgIB9rCXA==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.4.1 @@ -1032,8 +1126,8 @@ packages: react: 18.2.0 dev: false - /@mui/system@5.14.17(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react@18.2.0): - resolution: {integrity: sha512-Ccz3XlbCqka6DnbHfpL3o3TfOeWQPR+ewvNAgm8gnS9M0yVMmzzmY6z0w/C1eebb+7ZP7IoLUj9vojg/GBaTPg==} + /@mui/system@5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-hSQQdb3KF72X4EN2hMEiv8EYJZSflfdd1TRaGPoR7CIAG347OxCslpBUwWngYobaxgKvq6xTrlIl+diaactVww==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -1051,10 +1145,10 @@ packages: '@babel/runtime': 7.23.2 '@emotion/react': 11.11.1(@types/react@18.2.37)(react@18.2.0) '@emotion/styled': 11.11.0(@emotion/react@11.11.1)(@types/react@18.2.37)(react@18.2.0) - '@mui/private-theming': 5.14.17(@types/react@18.2.37)(react@18.2.0) - '@mui/styled-engine': 5.14.17(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(react@18.2.0) - '@mui/types': 7.2.8(@types/react@18.2.37) - '@mui/utils': 5.14.17(@types/react@18.2.37)(react@18.2.0) + '@mui/private-theming': 5.14.18(@types/react@18.2.37)(react@18.2.0) + '@mui/styled-engine': 5.14.18(@emotion/react@11.11.1)(@emotion/styled@11.11.0)(react@18.2.0) + '@mui/types': 7.2.9(@types/react@18.2.37) + '@mui/utils': 5.14.18(@types/react@18.2.37)(react@18.2.0) '@types/react': 18.2.37 clsx: 2.0.0 csstype: 3.1.2 @@ -1062,8 +1156,8 @@ packages: react: 18.2.0 dev: false - /@mui/types@7.2.8(@types/react@18.2.37): - resolution: {integrity: sha512-9u0ji+xspl96WPqvrYJF/iO+1tQ1L5GTaDOeG3vCR893yy7VcWwRNiVMmPdPNpMDqx0WV1wtEW9OMwK9acWJzQ==} + /@mui/types@7.2.9(@types/react@18.2.37): + resolution: {integrity: sha512-k1lN/PolaRZfNsRdAqXtcR71sTnv3z/VCCGPxU8HfdftDkzi335MdJ6scZxvofMAd/K/9EbzCZTFBmlNpQVdCg==} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 peerDependenciesMeta: @@ -1073,8 +1167,8 @@ packages: '@types/react': 18.2.37 dev: false - /@mui/utils@5.14.17(@types/react@18.2.37)(react@18.2.0): - resolution: {integrity: sha512-yxnWgSS4J6DMFPw2Dof85yBkG02VTbEiqsikymMsnZnXDurtVGTIhlNuV24GTmFTuJMzEyTTU9UF+O7zaL8LEQ==} + /@mui/utils@5.14.18(@types/react@18.2.37)(react@18.2.0): + resolution: {integrity: sha512-HZDRsJtEZ7WMSnrHV9uwScGze4wM/Y+u6pDVo+grUjt5yXzn+wI8QX/JwTHh9YSw/WpnUL80mJJjgCnWj2VrzQ==} engines: {node: '>=12.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 @@ -1097,12 +1191,10 @@ packages: dependencies: '@nodelib/fs.stat': 2.0.5 run-parallel: 1.2.0 - dev: true /@nodelib/fs.stat@2.0.5: resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} engines: {node: '>= 8'} - dev: true /@nodelib/fs.walk@1.2.8: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} @@ -1110,7 +1202,6 @@ packages: dependencies: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - dev: true /@popperjs/core@2.11.8: resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -1136,8 +1227,8 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@remix-run/router@1.11.0: - resolution: {integrity: sha512-BHdhcWgeiudl91HvVa2wxqZjSHbheSgIiDvxrF1VjFzBzpTtuDPkOdOi3Iqvc08kXtFkLjhbS+ML9aM8mJS+wQ==} + /@remix-run/router@1.12.0: + resolution: {integrity: sha512-2hXv036Bux90e1GXTWSMfNzfDDK8LA8JYEWfyHxzvwdp6GyoWEovKc9cotb3KCKmkdwsIBuFGX7ScTWyiHv7Eg==} engines: {node: '>=14.0.0'} dev: false @@ -1292,6 +1383,27 @@ packages: tailwindcss: 3.3.5 dev: true + /@tremor/react@3.11.1(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0)(tailwindcss@3.3.5): + resolution: {integrity: sha512-oiBm8vFe0+05RFIHlriSmfZX7BMwgAIFGdvz5kAEbN6G/cGOh2oPkTGG+NPbbk8eyo68f13IT6KfTiMVSEhRSA==} + peerDependencies: + react: ^18.0.0 + react-dom: '>=16.6.0' + dependencies: + '@floating-ui/react': 0.19.2(react-dom@18.2.0)(react@18.2.0) + '@headlessui/react': 1.7.17(react-dom@18.2.0)(react@18.2.0) + '@headlessui/tailwindcss': 0.1.3(tailwindcss@3.3.5) + date-fns: 2.30.0 + react: 18.2.0 + react-day-picker: 8.9.1(date-fns@2.30.0)(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 4.4.5(react-dom@18.2.0)(react@18.2.0) + recharts: 2.9.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + tailwind-merge: 1.14.0 + transitivePeerDependencies: + - prop-types + - tailwindcss + dev: false + /@types/babel__core@7.20.4: resolution: {integrity: sha512-mLnSC22IC4vcWiuObSRjrLd9XcBTGf59vUSoq2jkQDJ/QQ8PMI9rSuzE+aEV8karUMbskw07bKYoUJCKTUaygg==} dependencies: @@ -1321,6 +1433,48 @@ packages: '@babel/types': 7.23.3 dev: true + /@types/d3-array@3.2.1: + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + dev: false + + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-ease@3.0.2: + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-path@3.0.2: + resolution: {integrity: sha512-WAIEVlOCdd/NKRYTsqCpOMHQHemKBEINf8YXMYOtXH0GA7SY0dqMB78P3Uhgfy+4X+/Mlw2wDtlETkN6kQUCMA==} + dev: false + + /@types/d3-scale@4.0.8: + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + dependencies: + '@types/d3-time': 3.0.3 + dev: false + + /@types/d3-shape@3.1.5: + resolution: {integrity: sha512-dfEWpZJ1Pdg8meLlICX1M3WBIpxnaH2eQV2eY43Y5ysRJOTAV9f3/R++lgJKFstfrEOE2zdJ0sv5qwr2Bkic6Q==} + dependencies: + '@types/d3-path': 3.0.2 + dev: false + + /@types/d3-time@3.0.3: + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + dev: false + + /@types/d3-timer@3.0.2: + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + dev: false + /@types/hoist-non-react-statics@3.3.5: resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==} dependencies: @@ -1328,6 +1482,16 @@ packages: hoist-non-react-statics: 3.3.2 dev: false + /@types/lodash.memoize@4.1.9: + resolution: {integrity: sha512-glY1nQuoqX4Ft8Uk+KfJudOD7DQbbEDF6k9XpGncaohW3RW4eSWBlx6AA0fZCrh40tZcQNH4jS/Oc59J6Eq+aw==} + dependencies: + '@types/lodash': 4.14.201 + dev: false + + /@types/lodash@4.14.201: + resolution: {integrity: sha512-y9euML0cim1JrykNxADLfaG0FgD1g/yTHwUs/Jg9ZIU7WKj2/4IW9Lbb1WZbvck78W/lfGXFfe+u2EGfIJXdLQ==} + dev: false + /@types/parse-json@4.0.2: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} dev: false @@ -1339,7 +1503,6 @@ packages: resolution: {integrity: sha512-HWMdW+7r7MR5+PZqJF6YFNSCtjz1T0dsvo/f1BV6HkV+6erD/nA7wd9NM00KVG83zf2nJ7uATPO9ttdIPvi3gg==} dependencies: '@types/react': 18.2.37 - dev: true /@types/react-redux@7.1.30: resolution: {integrity: sha512-i2kqM6YaUwFKduamV6QM/uHbb0eCP8f8ZQ/0yWf+BsAVVsZPRYJ9eeGWZ3uxLfWwwA0SrPRMTPTqsPFkY3HZdA==} @@ -1374,11 +1537,11 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true - /@vitejs/plugin-react@4.1.1(vite@4.5.0): - resolution: {integrity: sha512-Jie2HERK+uh27e+ORXXwEP5h0Y2lS9T2PRGbfebiHGlwzDO0dEnd2aNtOR/qjBlPb1YgxwAONeblL1xqLikLag==} + /@vitejs/plugin-react@4.2.0(vite@4.5.0): + resolution: {integrity: sha512-+MHTH/e6H12kRp5HUkzOGqPMksezRMmW+TNzlh/QXfI8rRf6l2Z2yH/v12no1UvTwhZgEDMuQ7g7rrfMseU6FQ==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^4.2.0 + vite: ^4.2.0 || ^5.0.0 dependencies: '@babel/core': 7.23.3 '@babel/plugin-transform-react-jsx-self': 7.23.3(@babel/core@7.23.3) @@ -1390,6 +1553,33 @@ packages: - supports-color dev: true + /@wojtekmaj/date-utils@1.5.1: + resolution: {integrity: sha512-+i7+JmNiE/3c9FKxzWFi2IjRJ+KzZl1QPu6QNrsgaa2MuBgXvUy4gA1TVzf/JMdIIloB76xSKikTWuyYAIVLww==} + dev: false + + /@wojtekmaj/react-daterange-picker@5.4.4(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-EoHFD2SHG1ZeBfdQ9NYaO9MzcdKXy8gikxxFlO3f7HJ/tOGI6/Vxt752sgNnCGsF4JlqVoWVHweyxYOExumRdA==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + clsx: 2.0.0 + make-event-props: 1.6.2 + prop-types: 15.8.1 + react: 18.2.0 + react-calendar: 4.6.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-date-picker: 10.5.2(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-fit: 1.7.1(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - '@types/react-dom' + dev: false + /acorn-jsx@5.3.2(acorn@8.11.2): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1433,7 +1623,6 @@ packages: /any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - dev: true /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} @@ -1441,16 +1630,21 @@ packages: dependencies: normalize-path: 3.0.0 picomatch: 2.3.1 - dev: true /arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - dev: true /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true + /aria-hidden@1.2.3: + resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==} + engines: {node: '>=10'} + dependencies: + tslib: 2.6.2 + dev: false + /array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} dependencies: @@ -1530,7 +1724,7 @@ packages: postcss: ^8.1.0 dependencies: browserslist: 4.22.1 - caniuse-lite: 1.0.30001561 + caniuse-lite: 1.0.30001563 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.0 @@ -1543,8 +1737,8 @@ packages: engines: {node: '>= 0.4'} dev: true - /axios@1.6.1: - resolution: {integrity: sha512-vfBmhDpKafglh0EldBEbVuoe7DyAavGSLWhuSm5ZSEKQnHhBf0xAAwybbNH1IkrJNGnS/VG4I5yxig1pCEXE4g==} + /axios@1.6.2: + resolution: {integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==} dependencies: follow-redirects: 1.15.3 form-data: 4.0.0 @@ -1564,12 +1758,10 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} - dev: true /bootstrap@5.3.2(@popperjs/core@2.11.8): resolution: {integrity: sha512-D32nmNWiQHo94BKHLmOrdjlL05q1c8oxbtBphQFb9Z5to6eGRDCm0QgeaZ4zFBHzfg2++rqa2JkqCcxDy0sH0g==} @@ -1584,22 +1776,20 @@ packages: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - dev: true /braces@3.0.2: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} engines: {node: '>=8'} dependencies: fill-range: 7.0.1 - dev: true /browserslist@4.22.1: resolution: {integrity: sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001561 - electron-to-chromium: 1.4.581 + caniuse-lite: 1.0.30001563 + electron-to-chromium: 1.4.588 node-releases: 2.0.13 update-browserslist-db: 1.0.13(browserslist@4.22.1) dev: true @@ -1619,10 +1809,9 @@ packages: /camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - dev: true - /caniuse-lite@1.0.30001561: - resolution: {integrity: sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==} + /caniuse-lite@1.0.30001563: + resolution: {integrity: sha512-na2WUmOxnwIZtwnFI2CZ/3er0wdNzU7hN+cPYz/z2ajHThnkWjNBOpEPP4n+4r2WPM847JaMotaJE3bnfzjyKw==} dev: true /chalk@2.4.2: @@ -1654,12 +1843,15 @@ packages: readdirp: 3.6.0 optionalDependencies: fsevents: 2.3.3 - dev: true /classnames@2.3.2: resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==} dev: false + /client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + dev: false + /clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} @@ -1698,11 +1890,9 @@ packages: /commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - dev: true /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - dev: true /convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -1749,11 +1939,81 @@ packages: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true - dev: true /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + /daisyui@3.9.4: resolution: {integrity: sha512-fvi2RGH4YV617/6DntOVGcOugOPym9jTGWW2XySb5ZpvdWO4L7bEG77VHirrnbRUEWvIEVXkBpxUz2KFj0rVnA==} engines: {node: '>=16.9.0'} @@ -1767,6 +2027,13 @@ packages: - ts-node dev: true + /date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + dependencies: + '@babel/runtime': 7.23.2 + dev: false + /debug@4.3.4: resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -1779,6 +2046,10 @@ packages: ms: 2.1.2 dev: true + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -1811,13 +2082,15 @@ packages: engines: {node: '>=6'} dev: false + /detect-element-overflow@1.4.2: + resolution: {integrity: sha512-4m6cVOtvm/GJLjo7WFkPfwXoEIIbM7GQwIh4WEa4g7IsNi1YzwUsGL5ApNLrrHL29bHeNeQ+/iZhw+YHqgE2Fw==} + dev: false + /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - dev: true /dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dev: true /doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} @@ -1833,6 +2106,12 @@ packages: esutils: 2.0.3 dev: true + /dom-helpers@3.4.0: + resolution: {integrity: sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==} + dependencies: + '@babel/runtime': 7.23.2 + dev: false + /dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} dependencies: @@ -1845,8 +2124,8 @@ packages: engines: {node: '>=12'} dev: false - /electron-to-chromium@1.4.581: - resolution: {integrity: sha512-6uhqWBIapTJUxgPTCHH9sqdbxIMPt7oXl0VcAL1kOtlU6aECdcMncCrX5Z7sHQ/invtrC9jUQUef7+HhO8vVFw==} + /electron-to-chromium@1.4.588: + resolution: {integrity: sha512-soytjxwbgcCu7nh5Pf4S2/4wa6UIu+A3p03U2yVr53qGxi1/VTR3ENI+p50v+UxqqZAfl48j3z55ud7VHIOr9w==} dev: true /error-ex@1.3.2: @@ -1986,24 +2265,24 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - /eslint-plugin-react-hooks@4.6.0(eslint@8.53.0): + /eslint-plugin-react-hooks@4.6.0(eslint@8.54.0): resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} engines: {node: '>=10'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 dependencies: - eslint: 8.53.0 + eslint: 8.54.0 dev: true - /eslint-plugin-react-refresh@0.4.4(eslint@8.53.0): + /eslint-plugin-react-refresh@0.4.4(eslint@8.54.0): resolution: {integrity: sha512-eD83+65e8YPVg6603Om2iCIwcQJf/y7++MWm4tACtEswFLYMwxwVWAfwN+e19f5Ad/FOyyNg9Dfi5lXhH3Y3rA==} peerDependencies: eslint: '>=7' dependencies: - eslint: 8.53.0 + eslint: 8.54.0 dev: true - /eslint-plugin-react@7.33.2(eslint@8.53.0): + /eslint-plugin-react@7.33.2(eslint@8.54.0): resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==} engines: {node: '>=4'} peerDependencies: @@ -2014,7 +2293,7 @@ packages: array.prototype.tosorted: 1.1.2 doctrine: 2.1.0 es-iterator-helpers: 1.0.15 - eslint: 8.53.0 + eslint: 8.54.0 estraverse: 5.3.0 jsx-ast-utils: 3.3.5 minimatch: 3.1.2 @@ -2041,15 +2320,15 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint@8.53.0: - resolution: {integrity: sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==} + /eslint@8.54.0: + resolution: {integrity: sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.53.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.54.0) '@eslint-community/regexpp': 4.10.0 '@eslint/eslintrc': 2.1.3 - '@eslint/js': 8.53.0 + '@eslint/js': 8.54.0 '@humanwhocodes/config-array': 0.11.13 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 @@ -2071,7 +2350,7 @@ packages: glob-parent: 6.0.2 globals: 13.23.0 graphemer: 1.4.0 - ignore: 5.2.4 + ignore: 5.3.0 imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 @@ -2121,10 +2400,19 @@ packages: engines: {node: '>=0.10.0'} dev: true + /eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true + /fast-equals@5.0.1: + resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} + engines: {node: '>=6.0.0'} + dev: false + /fast-glob@3.3.2: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} @@ -2134,7 +2422,6 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 - dev: true /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -2152,13 +2439,12 @@ packages: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 - dev: true /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} dependencies: - flat-cache: 3.1.1 + flat-cache: 3.2.0 dev: true /fill-range@7.0.1: @@ -2166,7 +2452,6 @@ packages: engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - dev: true /find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} @@ -2180,9 +2465,9 @@ packages: path-exists: 4.0.0 dev: true - /flat-cache@3.1.1: - resolution: {integrity: sha512-/qM2b3LUIaIgviBQovTLvijfyOQXPtSRnRK26ksj2J7rzPIecePUIpJsZ4T02Qg+xiAEKIs5K8dsHEd+VaKa/Q==} - engines: {node: '>=12.0.0'} + /flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} dependencies: flatted: 3.2.9 keyv: 4.5.4 @@ -2222,8 +2507,8 @@ packages: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} dev: true - /framer-motion@10.16.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-p9V9nGomS3m6/CALXqv6nFGMuFOxbWsmaOrdmhyQimMIlLl3LC7h7l86wge/Js/8cRu5ktutS/zlzgR7eBOtFA==} + /framer-motion@10.16.5(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GEzVjOYP2MIpV9bT/GbhcsBNoImG3/2X3O/xVNWmktkv9MdJ7P/44zELm/7Fjb+O3v39SmKFnoDQB32giThzpg==} peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 @@ -2242,14 +2527,12 @@ packages: /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - dev: true /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true - dev: true optional: true /function-bind@1.1.2: @@ -2295,19 +2578,24 @@ packages: get-intrinsic: 1.2.2 dev: true + /get-user-locale@2.3.1: + resolution: {integrity: sha512-VEvcsqKYx7zhZYC1CjecrDC5ziPSpl1gSm0qFFJhHSGDrSC+x4+p1KojWC/83QX//j476gFhkVXP/kNUc9q+bQ==} + dependencies: + '@types/lodash.memoize': 4.1.9 + lodash.memoize: 4.1.2 + dev: false + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} dependencies: is-glob: 4.0.3 - dev: true /glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} dependencies: is-glob: 4.0.3 - dev: true /glob@7.1.6: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} @@ -2318,7 +2606,6 @@ packages: minimatch: 3.1.2 once: 1.4.0 path-is-absolute: 1.0.1 - dev: true /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -2408,8 +2695,8 @@ packages: react-is: 16.13.1 dev: false - /ignore@5.2.4: - resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + /ignore@5.3.0: + resolution: {integrity: sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==} engines: {node: '>= 4'} dev: true @@ -2430,11 +2717,9 @@ packages: dependencies: once: 1.4.0 wrappy: 1.0.2 - dev: true /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true /internal-slot@1.0.6: resolution: {integrity: sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==} @@ -2445,6 +2730,11 @@ packages: side-channel: 1.0.4 dev: true + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} dependencies: @@ -2481,7 +2771,6 @@ packages: engines: {node: '>=8'} dependencies: binary-extensions: 2.2.0 - dev: true /is-boolean-object@1.1.2: resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} @@ -2511,7 +2800,6 @@ packages: /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - dev: true /is-finalizationregistry@1.0.2: resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} @@ -2531,7 +2819,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: is-extglob: 2.1.1 - dev: true /is-map@2.0.2: resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} @@ -2552,7 +2839,6 @@ packages: /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - dev: true /is-path-inside@3.0.3: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} @@ -2636,7 +2922,6 @@ packages: /jiti@1.21.0: resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} hasBin: true - dev: true /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2708,7 +2993,6 @@ packages: /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} - dev: true /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2728,10 +3012,18 @@ packages: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} dev: true + /lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: false + /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2744,6 +3036,10 @@ packages: yallist: 3.1.1 dev: true + /make-event-props@1.6.2: + resolution: {integrity: sha512-iDwf7mA03WPiR8QxvcVHmVWEPfMY1RZXerDVNCRYW7dUr2ppH3J58Rwb39/WG39yTZdRSxr3x+2v22tvI0VEvA==} + dev: false + /memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} dev: false @@ -2751,7 +3047,6 @@ packages: /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - dev: true /micromatch@4.0.5: resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} @@ -2759,7 +3054,6 @@ packages: dependencies: braces: 3.0.2 picomatch: 2.3.1 - dev: true /mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} @@ -2777,7 +3071,6 @@ packages: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 - dev: true /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} @@ -2789,13 +3082,11 @@ packages: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 - dev: true /nanoid@3.3.7: resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: true /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -2808,7 +3099,6 @@ packages: /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - dev: true /normalize-range@0.1.2: resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} @@ -2822,7 +3112,6 @@ packages: /object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - dev: true /object-inspect@1.13.1: resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} @@ -2881,7 +3170,6 @@ packages: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: wrappy: 1.0.2 - dev: true /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} @@ -2933,7 +3221,6 @@ packages: /path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} - dev: true /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} @@ -2950,22 +3237,18 @@ packages: /picocolors@1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - dev: true /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - dev: true /pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} - dev: true /pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} - dev: true /postcss-import@15.1.0(postcss@8.4.31): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} @@ -2977,7 +3260,6 @@ packages: postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 - dev: true /postcss-js@4.0.1(postcss@8.4.31): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} @@ -2987,7 +3269,6 @@ packages: dependencies: camelcase-css: 2.0.1 postcss: 8.4.31 - dev: true /postcss-load-config@4.0.1(postcss@8.4.31): resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} @@ -3004,7 +3285,6 @@ packages: lilconfig: 2.1.0 postcss: 8.4.31 yaml: 2.3.4 - dev: true /postcss-nested@6.0.1(postcss@8.4.31): resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} @@ -3014,7 +3294,6 @@ packages: dependencies: postcss: 8.4.31 postcss-selector-parser: 6.0.13 - dev: true /postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} @@ -3030,11 +3309,9 @@ packages: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - dev: true /postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - dev: true /postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} @@ -3043,7 +3320,6 @@ packages: nanoid: 3.3.7 picocolors: 1.0.0 source-map-js: 1.0.2 - dev: true /preact@10.12.1: resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==} @@ -3082,7 +3358,6 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - dev: true /raf-schd@4.0.3: resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} @@ -3134,6 +3409,107 @@ packages: warning: 4.0.3 dev: false + /react-calendar@4.6.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-MvCPdvxEvq7wICBhFxlYwxS2+IsVvSjTcmlr0Kl3yDRVhoX7btNg0ySJx5hy9rb1eaM4nDpzQcW5c87nfQ8n8w==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + '@wojtekmaj/date-utils': 1.5.1 + clsx: 2.0.0 + get-user-locale: 2.3.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tiny-warning: 1.0.3 + dev: false + + /react-clock@4.5.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-xcSpsehBpX0NHwjEzZ9BP4Ouv54nlYqDMHoone82xW7TpPdkWNrBQd4+SiMQfbpqj1yvh2kSwn6FXffw37gAkw==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + '@wojtekmaj/date-utils': 1.5.1 + clsx: 2.0.0 + get-user-locale: 2.3.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-date-picker@10.5.2(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-EwLNYPy+/2p7VwsAWnitPhtkC2tesABkZNlAAIEPZeHucyMlO5KB6z55POdtamu6T6vs0RY2G5EVgxDaxlj0MQ==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + '@wojtekmaj/date-utils': 1.5.1 + clsx: 2.0.0 + get-user-locale: 2.3.1 + make-event-props: 1.6.2 + prop-types: 15.8.1 + react: 18.2.0 + react-calendar: 4.6.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-fit: 1.7.1(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + update-input-width: 1.4.2 + transitivePeerDependencies: + - '@types/react-dom' + dev: false + + /react-datetime-picker@5.5.3(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-bWGEPwGrZjaXTB8P4pbTSDygctLaqTWp0nNibaz8po+l4eTh9gv3yiJ+n4NIcpIJDqZaQJO57Bnij2rAFVQyLw==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + '@wojtekmaj/date-utils': 1.5.1 + clsx: 2.0.0 + get-user-locale: 2.3.1 + make-event-props: 1.6.2 + prop-types: 15.8.1 + react: 18.2.0 + react-calendar: 4.6.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-clock: 4.5.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-date-picker: 10.5.2(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-fit: 1.7.1(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-time-picker: 6.5.2(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - '@types/react-dom' + dev: false + + /react-day-picker@8.9.1(date-fns@2.30.0)(react@18.2.0): + resolution: {integrity: sha512-W0SPApKIsYq+XCtfGeMYDoU0KbsG3wfkYtlw8l+vZp6KoBXGOlhzBUp4tNx1XiwiOZwhfdGOlj7NGSCKGSlg5Q==} + peerDependencies: + date-fns: ^2.28.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + date-fns: 2.30.0 + react: 18.2.0 + dev: false + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -3144,8 +3520,30 @@ packages: scheduler: 0.23.0 dev: false - /react-icons@4.11.0(react@18.2.0): - resolution: {integrity: sha512-V+4khzYcE5EBk/BvcuYRq6V/osf11ODUM2J8hg2FDSswRrGvqiYUYPRy4OdrWaQOBj4NcpJfmHZLNaD+VH0TyA==} + /react-fit@1.7.1(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-y/TYovCCBzfIwRJsbLj0rH4Es40wPQhU5GPPq9GlbdF09b0OdzTdMSkBza0QixSlgFzTm6dkM7oTFzaVvaBx+w==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@types/react': 18.2.37 + '@types/react-dom': 18.2.15 + detect-element-overflow: 1.4.2 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tiny-warning: 1.0.3 + dev: false + + /react-icons@4.12.0(react@18.2.0): + resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==} peerDependencies: react: '*' dependencies: @@ -3194,29 +3592,93 @@ packages: engines: {node: '>=0.10.0'} dev: true - /react-router-dom@6.18.0(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Ubrue4+Ercc/BoDkFQfc6og5zRQ4A8YxSO3Knsne+eRbZ+IepAsK249XBH/XaFuOYOYr3L3r13CXTLvYt5JDjw==} + /react-resize-detector@8.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-S7szxlaIuiy5UqLhLL1KY3aoyGHbZzsTpYal9eYMwCyKqoqoVLCmIgAgNyIM1FhnP2KyBygASJxdhejrzjMb+w==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react-router-dom@6.19.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-N6dWlcgL2w0U5HZUUqU2wlmOrSb3ighJmtQ438SWbhB1yuLTXQ8yyTBMK3BSvVjp7gBtKurT554nCtMOgxCZmQ==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' dependencies: - '@remix-run/router': 1.11.0 + '@remix-run/router': 1.12.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-router: 6.18.0(react@18.2.0) + react-router: 6.19.0(react@18.2.0) dev: false - /react-router@6.18.0(react@18.2.0): - resolution: {integrity: sha512-vk2y7Dsy8wI02eRRaRmOs9g2o+aE72YCx5q9VasT1N9v+lrdB79tIqrjMfByHiY5+6aYkH2rUa5X839nwWGPDg==} + /react-router@6.19.0(react@18.2.0): + resolution: {integrity: sha512-0W63PKCZ7+OuQd7Tm+RbkI8kCLmn4GPjDbX61tWljPxWgqTKlEpeQUwPkT1DRjYhF8KSihK0hQpmhU4uxVMcdw==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' dependencies: - '@remix-run/router': 1.11.0 + '@remix-run/router': 1.12.0 react: 18.2.0 dev: false + /react-smooth@2.0.5(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-BMP2Ad42tD60h0JW6BFaib+RJuV5dsXJK9Baxiv/HlNFjvRLqA9xrNKxVWnUIZPQfzUwGXIlU/dSYLU+54YGQA==} + peerDependencies: + prop-types: ^15.6.0 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + fast-equals: 5.0.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-transition-group: 2.9.0(react-dom@18.2.0)(react@18.2.0) + dev: false + + /react-time-picker@6.5.2(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-xRamxjndpq3HfnEL+6T3VyirLMEn4D974OJgs9sTP8iJX/RB02rmwy09C9oBThTGuN3ycbozn06iYLn148vcdw==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.37 + '@wojtekmaj/date-utils': 1.5.1 + clsx: 2.0.0 + get-user-locale: 2.3.1 + make-event-props: 1.6.2 + prop-types: 15.8.1 + react: 18.2.0 + react-clock: 4.5.1(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + react-fit: 1.7.1(@types/react-dom@18.2.15)(@types/react@18.2.37)(react-dom@18.2.0)(react@18.2.0) + update-input-width: 1.4.2 + transitivePeerDependencies: + - '@types/react-dom' + dev: false + + /react-transition-group@2.9.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==} + peerDependencies: + react: '>=15.0.0' + react-dom: '>=15.0.0' + dependencies: + dom-helpers: 3.4.0 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-lifecycles-compat: 3.0.4 + dev: false + /react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -3231,6 +3693,16 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-tsparticles@2.12.2(react@18.2.0): + resolution: {integrity: sha512-/nrEbyL8UROXKIMXe+f+LZN2ckvkwV2Qa+GGe/H26oEIc+wq/ybSG9REDwQiSt2OaDQGu0MwmA4BKmkL6wAWcA==} + requiresBuild: true + peerDependencies: + react: '>=16' + dependencies: + react: 18.2.0 + tsparticles-engine: 2.12.0 + dev: false + /react@18.2.0: resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} engines: {node: '>=0.10.0'} @@ -3242,14 +3714,40 @@ packages: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} dependencies: pify: 2.3.0 - dev: true /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} dependencies: picomatch: 2.3.1 - dev: true + + /recharts-scale@0.4.5: + resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} + dependencies: + decimal.js-light: 2.5.1 + dev: false + + /recharts@2.9.3(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-B61sKrDlTxHvYwOCw8eYrD6rTA2a2hJg0avaY8qFI1ZYdHKvU18+J5u7sBMFg//wfJ/C5RL5+HsXt5e8tcJNLg==} + engines: {node: '>=12'} + peerDependencies: + prop-types: ^15.6.0 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + classnames: 2.3.2 + eventemitter3: 4.0.7 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 16.13.1 + react-resize-detector: 8.1.0(react-dom@18.2.0)(react@18.2.0) + react-smooth: 2.0.5(prop-types@15.8.1)(react-dom@18.2.0)(react@18.2.0) + recharts-scale: 0.4.5 + tiny-invariant: 1.3.1 + victory-vendor: 36.6.12 + dev: false /redux@4.2.1: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} @@ -3306,7 +3804,6 @@ packages: /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} @@ -3327,7 +3824,6 @@ packages: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: queue-microtask: 1.2.3 - dev: true /safe-array-concat@1.0.1: resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} @@ -3400,7 +3896,6 @@ packages: /source-map-js@1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} - dev: true /source-map@0.5.7: resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} @@ -3474,7 +3969,6 @@ packages: mz: 2.7.0 pirates: 4.0.6 ts-interface-checker: 0.1.13 - dev: true /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} @@ -3493,6 +3987,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + /tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + dev: false + + /tailwind-merge@1.14.0: + resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==} + dev: false + /tailwindcss@3.3.5: resolution: {integrity: sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==} engines: {node: '>=14.0.0'} @@ -3522,7 +4024,6 @@ packages: sucrase: 3.34.0 transitivePeerDependencies: - ts-node - dev: true /text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -3533,18 +4034,20 @@ packages: engines: {node: '>=0.8'} dependencies: thenify: 3.3.1 - dev: true /thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} dependencies: any-promise: 1.3.0 - dev: true /tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} dev: false + /tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + dev: false + /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} @@ -3554,16 +4057,318 @@ packages: engines: {node: '>=8.0'} dependencies: is-number: 7.0.0 - dev: true /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - dev: true /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} dev: false + /tsparticles-basic@2.12.0: + resolution: {integrity: sha512-pN6FBpL0UsIUXjYbiui5+IVsbIItbQGOlwyGV55g6IYJBgdTNXgFX0HRYZGE9ZZ9psEXqzqwLM37zvWnb5AG9g==} + dependencies: + tsparticles-engine: 2.12.0 + tsparticles-move-base: 2.12.0 + tsparticles-shape-circle: 2.12.0 + tsparticles-updater-color: 2.12.0 + tsparticles-updater-opacity: 2.12.0 + tsparticles-updater-out-modes: 2.12.0 + tsparticles-updater-size: 2.12.0 + dev: false + + /tsparticles-engine@2.12.0: + resolution: {integrity: sha512-ZjDIYex6jBJ4iMc9+z0uPe7SgBnmb6l+EJm83MPIsOny9lPpetMsnw/8YJ3xdxn8hV+S3myTpTN1CkOVmFv0QQ==} + requiresBuild: true + dev: false + + /tsparticles-interaction-external-attract@2.12.0: + resolution: {integrity: sha512-0roC6D1QkFqMVomcMlTaBrNVjVOpyNzxIUsjMfshk2wUZDAvTNTuWQdUpmsLS4EeSTDN3rzlGNnIuuUQqyBU5w==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-bounce@2.12.0: + resolution: {integrity: sha512-MMcqKLnQMJ30hubORtdq+4QMldQ3+gJu0bBYsQr9BsThsh8/V0xHc1iokZobqHYVP5tV77mbFBD8Z7iSCf0TMQ==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-bubble@2.12.0: + resolution: {integrity: sha512-5kImCSCZlLNccXOHPIi2Yn+rQWTX3sEa/xCHwXW19uHxtILVJlnAweayc8+Zgmb7mo0DscBtWVFXHPxrVPFDUA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-connect@2.12.0: + resolution: {integrity: sha512-ymzmFPXz6AaA1LAOL5Ihuy7YSQEW8MzuSJzbd0ES13U8XjiU3HlFqlH6WGT1KvXNw6WYoqrZt0T3fKxBW3/C3A==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-grab@2.12.0: + resolution: {integrity: sha512-iQF/A947hSfDNqAjr49PRjyQaeRkYgTYpfNmAf+EfME8RsbapeP/BSyF6mTy0UAFC0hK2A2Hwgw72eT78yhXeQ==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-pause@2.12.0: + resolution: {integrity: sha512-4SUikNpsFROHnRqniL+uX2E388YTtfRWqqqZxRhY0BrijH4z04Aii3YqaGhJxfrwDKkTQlIoM2GbFT552QZWjw==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-push@2.12.0: + resolution: {integrity: sha512-kqs3V0dgDKgMoeqbdg+cKH2F+DTrvfCMrPF1MCCUpBCqBiH+TRQpJNNC86EZYHfNUeeLuIM3ttWwIkk2hllR/Q==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-remove@2.12.0: + resolution: {integrity: sha512-2eNIrv4m1WB2VfSVj46V2L/J9hNEZnMgFc+A+qmy66C8KzDN1G8aJUAf1inW8JVc0lmo5+WKhzex4X0ZSMghBg==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-repulse@2.12.0: + resolution: {integrity: sha512-rSzdnmgljeBCj5FPp4AtGxOG9TmTsK3AjQW0vlyd1aG2O5kSqFjR+FuT7rfdSk9LEJGH5SjPFE6cwbuy51uEWA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-slow@2.12.0: + resolution: {integrity: sha512-2IKdMC3om7DttqyroMtO//xNdF0NvJL/Lx7LDo08VpfTgJJozxU+JAUT8XVT7urxhaDzbxSSIROc79epESROtA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-external-trail@2.12.0: + resolution: {integrity: sha512-LKSapU5sPTaZqYx+y5VJClj0prlV7bswplSFQaIW1raXkvsk45qir2AVcpP5JUhZSFSG+SwsHr+qCgXhNeN1KA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-particles-attract@2.12.0: + resolution: {integrity: sha512-Hl8qwuwF9aLq3FOkAW+Zomu7Gb8IKs6Y3tFQUQScDmrrSCaeRt2EGklAiwgxwgntmqzL7hbMWNx06CHHcUQKdQ==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-particles-collisions@2.12.0: + resolution: {integrity: sha512-Se9nPWlyPxdsnHgR6ap4YUImAu3W5MeGKJaQMiQpm1vW8lSMOUejI1n1ioIaQth9weKGKnD9rvcNn76sFlzGBA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-interaction-particles-links@2.12.0: + resolution: {integrity: sha512-e7I8gRs4rmKfcsHONXMkJnymRWpxHmeaJIo4g2NaDRjIgeb2AcJSWKWZvrsoLnm7zvaf/cMQlbN6vQwCixYq3A==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-move-base@2.12.0: + resolution: {integrity: sha512-oSogCDougIImq+iRtIFJD0YFArlorSi8IW3HD2gO3USkH+aNn3ZqZNTqp321uB08K34HpS263DTbhLHa/D6BWw==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-move-parallax@2.12.0: + resolution: {integrity: sha512-58CYXaX8Ih5rNtYhpnH0YwU4Ks7gVZMREGUJtmjhuYN+OFr9FVdF3oDIJ9N6gY5a5AnAKz8f5j5qpucoPRcYrQ==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-particles.js@2.12.0: + resolution: {integrity: sha512-LyOuvYdhbUScmA4iDgV3LxA0HzY1DnOwQUy3NrPYO393S2YwdDjdwMod6Btq7EBUjg9FVIh+sZRizgV5elV2dg==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-plugin-absorbers@2.12.0: + resolution: {integrity: sha512-2CkPreaXHrE5VzFlxUKLeRB5t66ff+3jwLJoDFgQcp+R4HOEITo0bBZv2DagGP0QZdYN4grpnQzRBVdB4d1rWA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-plugin-easing-quad@2.12.0: + resolution: {integrity: sha512-2mNqez5pydDewMIUWaUhY5cNQ80IUOYiujwG6qx9spTq1D6EEPLbRNAEL8/ecPdn2j1Um3iWSx6lo340rPkv4Q==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-plugin-emitters@2.12.0: + resolution: {integrity: sha512-fbskYnaXWXivBh9KFReVCfqHdhbNQSK2T+fq2qcGEWpwtDdgujcaS1k2Q/xjZnWNMfVesik4IrqspcL51gNdSA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-shape-circle@2.12.0: + resolution: {integrity: sha512-L6OngbAlbadG7b783x16ns3+SZ7i0SSB66M8xGa5/k+YcY7zm8zG0uPt1Hd+xQDR2aNA3RngVM10O23/Lwk65Q==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-shape-image@2.12.0: + resolution: {integrity: sha512-iCkSdUVa40DxhkkYjYuYHr9MJGVw+QnQuN5UC+e/yBgJQY+1tQL8UH0+YU/h0GHTzh5Sm+y+g51gOFxHt1dj7Q==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-shape-line@2.12.0: + resolution: {integrity: sha512-RcpKmmpKlk+R8mM5wA2v64Lv1jvXtU4SrBDv3vbdRodKbKaWGGzymzav1Q0hYyDyUZgplEK/a5ZwrfrOwmgYGA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-shape-polygon@2.12.0: + resolution: {integrity: sha512-5YEy7HVMt1Obxd/jnlsjajchAlYMr9eRZWN+lSjcFSH6Ibra7h59YuJVnwxOxAobpijGxsNiBX0PuGQnB47pmA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-shape-square@2.12.0: + resolution: {integrity: sha512-33vfajHqmlODKaUzyPI/aVhnAOT09V7nfEPNl8DD0cfiNikEuPkbFqgJezJuE55ebtVo7BZPDA9o7GYbWxQNuw==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-shape-star@2.12.0: + resolution: {integrity: sha512-4sfG/BBqm2qBnPLASl2L5aBfCx86cmZLXeh49Un+TIR1F5Qh4XUFsahgVOG0vkZQa+rOsZPEH04xY5feWmj90g==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-shape-text@2.12.0: + resolution: {integrity: sha512-v2/FCA+hyTbDqp2ymFOe97h/NFb2eezECMrdirHWew3E3qlvj9S/xBibjbpZva2gnXcasBwxn0+LxKbgGdP0rA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-slim@2.12.0: + resolution: {integrity: sha512-27w9aGAAAPKHvP4LHzWFpyqu7wKyulayyaZ/L6Tuuejy4KP4BBEB4rY5GG91yvAPsLtr6rwWAn3yS+uxnBDpkA==} + dependencies: + tsparticles-basic: 2.12.0 + tsparticles-engine: 2.12.0 + tsparticles-interaction-external-attract: 2.12.0 + tsparticles-interaction-external-bounce: 2.12.0 + tsparticles-interaction-external-bubble: 2.12.0 + tsparticles-interaction-external-connect: 2.12.0 + tsparticles-interaction-external-grab: 2.12.0 + tsparticles-interaction-external-pause: 2.12.0 + tsparticles-interaction-external-push: 2.12.0 + tsparticles-interaction-external-remove: 2.12.0 + tsparticles-interaction-external-repulse: 2.12.0 + tsparticles-interaction-external-slow: 2.12.0 + tsparticles-interaction-particles-attract: 2.12.0 + tsparticles-interaction-particles-collisions: 2.12.0 + tsparticles-interaction-particles-links: 2.12.0 + tsparticles-move-base: 2.12.0 + tsparticles-move-parallax: 2.12.0 + tsparticles-particles.js: 2.12.0 + tsparticles-plugin-easing-quad: 2.12.0 + tsparticles-shape-circle: 2.12.0 + tsparticles-shape-image: 2.12.0 + tsparticles-shape-line: 2.12.0 + tsparticles-shape-polygon: 2.12.0 + tsparticles-shape-square: 2.12.0 + tsparticles-shape-star: 2.12.0 + tsparticles-shape-text: 2.12.0 + tsparticles-updater-color: 2.12.0 + tsparticles-updater-life: 2.12.0 + tsparticles-updater-opacity: 2.12.0 + tsparticles-updater-out-modes: 2.12.0 + tsparticles-updater-rotate: 2.12.0 + tsparticles-updater-size: 2.12.0 + tsparticles-updater-stroke-color: 2.12.0 + dev: false + + /tsparticles-updater-color@2.12.0: + resolution: {integrity: sha512-KcG3a8zd0f8CTiOrylXGChBrjhKcchvDJjx9sp5qpwQK61JlNojNCU35xoaSk2eEHeOvFjh0o3CXWUmYPUcBTQ==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-destroy@2.12.0: + resolution: {integrity: sha512-6NN3dJhxACvzbIGL4dADbYQSZJmdHfwjujj1uvnxdMbb2x8C/AZzGxiN33smo4jkrZ5VLEWZWCJPJ8aOKjQ2Sg==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-life@2.12.0: + resolution: {integrity: sha512-J7RWGHAZkowBHpcLpmjKsxwnZZJ94oGEL2w+wvW1/+ZLmAiFFF6UgU0rHMC5CbHJT4IPx9cbkYMEHsBkcRJ0Bw==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-opacity@2.12.0: + resolution: {integrity: sha512-YUjMsgHdaYi4HN89LLogboYcCi1o9VGo21upoqxq19yRy0hRCtx2NhH22iHF/i5WrX6jqshN0iuiiNefC53CsA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-out-modes@2.12.0: + resolution: {integrity: sha512-owBp4Gk0JNlSrmp12XVEeBroDhLZU+Uq3szbWlHGSfcR88W4c/0bt0FiH5bHUqORIkw+m8O56hCjbqwj69kpOQ==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-roll@2.12.0: + resolution: {integrity: sha512-dxoxY5jP4C9x15BxlUv5/Q8OjUPBiE09ToXRyBxea9aEJ7/iMw6odvi1HuT0H1vTIfV7o1MYawjeCbMycvODKQ==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-rotate@2.12.0: + resolution: {integrity: sha512-waOFlGFmEZOzsQg4C4VSejNVXGf4dMf3fsnQrEROASGf1FCd8B6WcZau7JtXSTFw0OUGuk8UGz36ETWN72DkCw==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-size@2.12.0: + resolution: {integrity: sha512-B0yRdEDd/qZXCGDL/ussHfx5YJ9UhTqNvmS5X2rR2hiZhBAE2fmsXLeWkdtF2QusjPeEqFDxrkGiLOsh6poqRA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-stroke-color@2.12.0: + resolution: {integrity: sha512-MPou1ZDxsuVq6SN1fbX+aI5yrs6FyP2iPCqqttpNbWyL+R6fik1rL0ab/x02B57liDXqGKYomIbBQVP3zUTW1A==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-tilt@2.12.0: + resolution: {integrity: sha512-HDEFLXazE+Zw+kkKKAiv0Fs9D9sRP61DoCR6jZ36ipea6OBgY7V1Tifz2TSR1zoQkk57ER9+EOQbkSQO+YIPGQ==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-twinkle@2.12.0: + resolution: {integrity: sha512-JhK/DO4kTx7IFwMBP2EQY9hBaVVvFnGBvX21SQWcjkymmN1hZ+NdcgUtR9jr4jUiiSNdSl7INaBuGloVjWvOgA==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles-updater-wobble@2.12.0: + resolution: {integrity: sha512-85FIRl95ipD3jfIsQdDzcUC5PRMWIrCYqBq69nIy9P8rsNzygn+JK2n+P1VQZowWsZvk0mYjqb9OVQB21Lhf6Q==} + dependencies: + tsparticles-engine: 2.12.0 + dev: false + + /tsparticles@2.12.0: + resolution: {integrity: sha512-aw77llkaEhcKYUHuRlggA6SB1Dpa814/nrStp9USGiDo5QwE1Ckq30QAgdXU6GRvnblUFsiO750ZuLQs5Y0tVw==} + dependencies: + tsparticles-engine: 2.12.0 + tsparticles-interaction-external-trail: 2.12.0 + tsparticles-plugin-absorbers: 2.12.0 + tsparticles-plugin-emitters: 2.12.0 + tsparticles-slim: 2.12.0 + tsparticles-updater-destroy: 2.12.0 + tsparticles-updater-roll: 2.12.0 + tsparticles-updater-tilt: 2.12.0 + tsparticles-updater-twinkle: 2.12.0 + tsparticles-updater-wobble: 2.12.0 + dev: false + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -3654,6 +4459,10 @@ packages: picocolors: 1.0.0 dev: true + /update-input-width@1.4.2: + resolution: {integrity: sha512-/p0XLhrQQQ4bMWD7bL9duYObwYCO1qGr8R19xcMmoMSmXuQ7/1//veUnCObQ7/iW6E2pGS6rFkS4TfH4ur7e/g==} + dev: false + /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} dependencies: @@ -3670,7 +4479,25 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true + + /victory-vendor@36.6.12: + resolution: {integrity: sha512-pJrTkNHln+D83vDCCSUf0ZfxBvIaVrFHmrBOsnnLAbdqfudRACAj51He2zU94/IWq9464oTADcPVkmWAfNMwgA==} + dependencies: + '@types/d3-array': 3.2.1 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.5 + '@types/d3-time': 3.0.3 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + dev: false /vite@4.5.0: resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} @@ -3771,7 +4598,6 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3785,7 +4611,6 @@ packages: /yaml@2.3.4: resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} engines: {node: '>= 14'} - dev: true /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 78f312c..a917ca5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,41 +1,105 @@ import "./App.css"; -import { Route, Routes, useLocation } from "react-router-dom"; +import axios from "axios"; +import { useEffect } from "react"; +import { Route, Routes, Navigate } from "react-router-dom"; +import { LoginPage } from "./components/authentication/LoginPage"; +import { SignUp } from "./components/authentication/SignUpPage"; +import { NavBar } from "./components/navigations/Navbar"; +import { Calendar } from "./components/calendar/calendar"; +import { KanbanPage } from "./components/kanbanBoard/kanbanPage"; +import { SideNav } from "./components/navigations/IconSideNav"; +import { Eisenhower } from "./components/EisenhowerMatrix/Eisenhower"; +import { PrivateRoute } from "./PrivateRoute"; +import { ProfileUpdatePage } from "./components/profile/profilePage"; +import { Dashboard } from "./components/dashboard/dashboard"; +import { LandingPage } from "./components/landingPage/LandingPage"; +import { PublicRoute } from "./PublicRoute"; +import { useAuth } from "./hooks/AuthHooks"; -import TestAuth from "./components/testAuth"; -import LoginPage from "./components/authentication/LoginPage"; -import SignUpPage from "./components/authentication/SignUpPage"; -import NavBar from "./components/navigations/Navbar"; -import Home from "./components/Home"; -import ProfileUpdate from "./components/ProfileUpdatePage"; -import Calendar from "./components/calendar/calendar"; -import KanbanBoard from "./components/kanbanBoard/kanbanBoard"; -import IconSideNav from "./components/navigations/IconSideNav"; -import Eisenhower from "./components/eisenhowerMatrix/Eisenhower"; +const baseURL = import.meta.env.VITE_BASE_URL; const App = () => { - const location = useLocation(); - const prevention = ["/login", "/signup"]; - const isLoginPageOrSignUpPage = prevention.some(_ => location.pathname.includes(_)); + const { isAuthenticated, setIsAuthenticated } = useAuth(); + useEffect(() => { + const checkLoginStatus = async () => { + const data = { + access_token: localStorage.getItem("access_token"), + refresh_token: localStorage.getItem("refresh_token"), + }; + + await axios + .post(`${baseURL}auth/status/`, data, { + headers: { + Authorization: "Bearer " + localStorage.getItem("access_token"), + }, + }) + .then((response) => { + if (response.status === 200) { + if (response.data.access_token) { + localStorage.setItem("access_token", response.data.access_token); + setIsAuthenticated(true); + } else { + setIsAuthenticated(true); + } + } else { + setIsAuthenticated(false); + } + }) + .catch((error) => {}); + }; + + checkLoginStatus(); + }, [setIsAuthenticated]); + + return
{isAuthenticated ? : }
; +}; + +const NonAuthenticatedComponents = () => { return ( -
- {!isLoginPageOrSignUpPage && } -
- -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - -
+
+ + }> + } /> + + }> + } /> + + }> + } /> + + } /> + +
+ ); +}; + +const AuthenticatedComponents = () => { + return ( +
+ +
+ +
+ + } /> + }> + } /> + + }> + } /> + + }> + } /> + + }> + } /> + + } /> +
+
); }; diff --git a/frontend/src/PrivateRoute.jsx b/frontend/src/PrivateRoute.jsx new file mode 100644 index 0000000..3daa62b --- /dev/null +++ b/frontend/src/PrivateRoute.jsx @@ -0,0 +1,7 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "src/hooks/AuthHooks"; + +export const PrivateRoute = () => { + const { isAuthenticated } = useAuth(); + return isAuthenticated ? : ; +}; diff --git a/frontend/src/PublicRoute.jsx b/frontend/src/PublicRoute.jsx new file mode 100644 index 0000000..69f980a --- /dev/null +++ b/frontend/src/PublicRoute.jsx @@ -0,0 +1,7 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "src/hooks/AuthHooks"; + +export const PublicRoute = () => { + const { isAuthenticated } = useAuth(); + return isAuthenticated ? : ; +}; diff --git a/frontend/src/api/AuthenticationApi.jsx b/frontend/src/api/AuthenticationApi.jsx index d4b6486..e029404 100644 --- a/frontend/src/api/AuthenticationApi.jsx +++ b/frontend/src/api/AuthenticationApi.jsx @@ -1,72 +1,43 @@ import axios from "axios"; -import axiosInstance from "./configs/AxiosConfig"; +import { axiosInstance } from "./AxiosConfig"; + +const baseURL = import.meta.env.VITE_BASE_URL; // Function for user login -const apiUserLogin = data => { +export const apiUserLogin = (data) => { return axiosInstance .post("token/obtain/", data) - .then(response => { - console.log(response.statusText); - return response; - }) - .catch(error => { - console.log("apiUserLogin error: ", error); - return error; + .then((response) => response) + .catch((error) => { + throw error; }); }; // Function for user logout -const apiUserLogout = () => { +export const apiUserLogout = () => { axiosInstance.defaults.headers["Authorization"] = ""; // Clear authorization header localStorage.removeItem("access_token"); // Remove access token localStorage.removeItem("refresh_token"); // Remove refresh token }; // Function for Google login -const googleLogin = async token => { +export const googleLogin = async (token) => { axios.defaults.withCredentials = true; - let res = await axios.post("http://localhost:8000/api/auth/google/", { + let res = await axios.post(`${baseURL}auth/google/`, { code: token, }); // console.log('service google login res: ', res); return await res; }; -// Function to get 'hello' data -const getGreeting = () => { - return axiosInstance - .get("hello") - .then(response => { - return response; - }) - .catch(error => { - return error; - }); -}; - -const config = { - headers: { - "Content-Type": "application/json", - }, -}; - // Function to register -const createUser = async formData => { +export const createUser = async (formData) => { try { axios.defaults.withCredentials = true; - const resposne = axios.post("http://localhost:8000/api/user/create/", formData); - // const response = await axiosInstance.post('/user/create/', formData); + const response = await axios.post(`${baseURL}user/create/`, formData); return response.data; - } catch (error) { - throw error; + } catch (e) { + console.error("Error in createUser function:", e); + throw e; } }; - -// Export the functions and Axios instance -export default { - apiUserLogin, - apiUserLogout, - getGreeting: getGreeting, - googleLogin, - createUser, -}; diff --git a/frontend/src/api/AxiosConfig.jsx b/frontend/src/api/AxiosConfig.jsx new file mode 100644 index 0000000..29ed6c7 --- /dev/null +++ b/frontend/src/api/AxiosConfig.jsx @@ -0,0 +1,53 @@ +import axios from "axios"; +import { redirect } from "react-router-dom"; + +const baseURL = import.meta.env.VITE_BASE_URL; + +export const axiosInstance = axios.create({ + baseURL: baseURL, + timeout: 5000, + headers: { + Authorization: "Bearer " + localStorage.getItem("access_token"), + "Content-Type": "application/json", + accept: "application/json", + }, +}); + +axiosInstance.interceptors.request.use((config) => { + const access_token = localStorage.getItem("access_token"); + if (access_token) { + config.headers.Authorization = `Bearer ${access_token}`; + } + return config; +}); + +// handling token refresh on 401 Unauthorized errors +axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + const originalRequest = error.config; + const refresh_token = localStorage.getItem("refresh_token"); + + // Check if the error is due to 401 and a refresh token is available + if (error.response && error.response.status === 401) { + if (refresh_token) { + return axiosInstance + .post("/token/refresh/", { refresh: refresh_token }) + .then((response) => { + localStorage.setItem("access_token", response.data.access); + + axiosInstance.defaults.headers["Authorization"] = "Bearer " + response.data.access; + originalRequest.headers["Authorization"] = "Bearer " + response.data.access; + + return axiosInstance(originalRequest); + }) + .catch((err) => { + console.log("Interceptors error: ", err); + }); + } else { + redirect("/login"); + } + } + return Promise.reject(error); + } +); diff --git a/frontend/src/api/TagApi.jsx b/frontend/src/api/TagApi.jsx index deea971..5d352aa 100644 --- a/frontend/src/api/TagApi.jsx +++ b/frontend/src/api/TagApi.jsx @@ -1,12 +1,8 @@ -import axiosInstance from "./configs/AxiosConfig"; +import { createTask, readTasks, readTaskByID, updateTask, deleteTask } from "./TaskApi"; -export const fetchTags = () => { - return axiosInstance - .get("tags/") - .then(response => { - return response.data; - }) - .catch(error => { - throw error; - }); -}; +// CRUD functions for "tags" endpoint +export const createTag = data => createTask("tags", data); +export const readTags = () => readTasks("tags"); +export const readTagByID = id => readTaskByID("tags", id); +export const updateTag = (id, data) => updateTask("tags", id, data); +export const deleteTag = id => deleteTask("tags", id); diff --git a/frontend/src/api/TaskApi.jsx b/frontend/src/api/TaskApi.jsx index 96f24f6..098d934 100644 --- a/frontend/src/api/TaskApi.jsx +++ b/frontend/src/api/TaskApi.jsx @@ -1,23 +1,73 @@ -import axiosInstance from "./configs/AxiosConfig"; +import { axiosInstance } from "src/api/AxiosConfig"; -export const fetchTodoTasks = () => { +const baseURL = import.meta.env.VITE_BASE_URL; + +export const createTask = (endpoint, data) => { return axiosInstance - .get("todo/") - .then(response => { - return response.data; - }) - .catch(error => { + .post(`${baseURL}${endpoint}/`, data) + .then((response) => response.data) + .catch((error) => { throw error; }); }; -export const fetchTodoTasksID = id => { +export const readTasks = (endpoint) => { return axiosInstance - .get(`todo/${id}/`) - .then(response => { - return response.data; - }) - .catch(error => { + .get(`${baseURL}${endpoint}/`) + .then((response) => response.data) + .catch((error) => { throw error; }); }; + +export const readTaskByID = (endpoint, id) => { + return axiosInstance + .get(`${baseURL}${endpoint}/${id}/`) + .then((response) => response.data) + .catch((error) => { + throw error; + }); +}; + +export const updateTask = (endpoint, id, data) => { + return axiosInstance + .put(`${baseURL}${endpoint}/${id}/`, data) + .then((response) => response.data) + .catch((error) => { + throw error; + }); +}; + +export const deleteTask = (endpoint, id) => { + return axiosInstance + .delete(`${baseURL}${endpoint}/${id}/`) + .then((response) => response.data) + .catch((error) => { + throw error; + }); +}; + +// Create +export const createTodoTask = (data) => createTask("todo", data); +export const createRecurrenceTask = (data) => createTask("daily", data); +export const createHabitTask = (data) => createTask("habit", data); + +// Read +export const readTodoTasks = () => readTasks("todo"); +export const readRecurrenceTasks = () => readTasks("daily"); +export const readHabitTasks = () => readTasks("habit"); + +// Read by ID +export const readTodoTaskByID = (id) => readTaskByID("todo", id); +export const readRecurrenceTaskByID = (id) => readTaskByID("daily", id); +export const readHabitTaskByID = (id) => readTaskByID("habit", id); + +// Update +export const updateTodoTask = (id, data) => updateTask("todo", id, data); +export const updateRecurrenceTask = (id, data) => updateTask("daily", id, data); +export const updateHabitTask = (id, data) => updateTask("habit", id, data); + +// Delete +export const deleteTodoTask = (id) => deleteTask("todo", id); +export const deleteRecurrenceTask = (id) => deleteTask("daily", id); +export const deleteHabitTask = (id) => deleteTask("habit", id); diff --git a/frontend/src/api/UserProfileApi.jsx b/frontend/src/api/UserProfileApi.jsx index ca80fd1..6dfc050 100644 --- a/frontend/src/api/UserProfileApi.jsx +++ b/frontend/src/api/UserProfileApi.jsx @@ -1,11 +1,13 @@ -import axios from 'axios'; +import axios from "axios"; + +const baseURL = import.meta.env.VITE_BASE_URL; const ApiUpdateUserProfile = async (formData) => { try { - const response = await axios.post('http://127.0.1:8000/api/user/update/', formData, { + const response = await axios.post(`${baseURL}user/update/`, formData, { headers: { - 'Authorization': "Bearer " + localStorage.getItem('access_token'), - 'Content-Type': 'multipart/form-data', + Authorization: "Bearer " + localStorage.getItem("access_token"), + "Content-Type": "multipart/form-data", }, }); @@ -13,7 +15,7 @@ const ApiUpdateUserProfile = async (formData) => { return response.data; } catch (error) { - console.error('Error updating user profile:', error); + console.error("Error updating user profile:", error); throw error; } }; diff --git a/frontend/src/api/configs/AxiosConfig.jsx b/frontend/src/api/configs/AxiosConfig.jsx deleted file mode 100644 index 80015ac..0000000 --- a/frontend/src/api/configs/AxiosConfig.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import axios from 'axios'; - -const axiosInstance = axios.create({ - baseURL: 'http://127.0.0.1:8000/api/', - timeout: 5000, - headers: { - 'Authorization': "Bearer " + localStorage.getItem('access_token'), - 'Content-Type': 'application/json', - 'accept': 'application/json', - }, -}); - -// handling token refresh on 401 Unauthorized errors -axiosInstance.interceptors.response.use( - response => response, - error => { - const originalRequest = error.config; - const refresh_token = localStorage.getItem('refresh_token'); - - // Check if the error is due to 401 and a refresh token is available - if (error.response.status === 401 && error.response.statusText === "Unauthorized" && refresh_token !== "undefined") { - return axiosInstance - .post('/token/refresh/', { refresh: refresh_token }) - .then((response) => { - - localStorage.setItem('access_token', response.data.access); - - axiosInstance.defaults.headers['Authorization'] = "Bearer " + response.data.access; - originalRequest.headers['Authorization'] = "Bearer " + response.data.access; - - return axiosInstance(originalRequest); - }) - .catch(err => { - console.log('Interceptors error: ', err); - }); - } - return Promise.reject(error); - } -); - - -export default axiosInstance; diff --git a/frontend/src/assets/calendar.png b/frontend/src/assets/calendar.png deleted file mode 100644 index 8c30ce3..0000000 Binary files a/frontend/src/assets/calendar.png and /dev/null differ diff --git a/frontend/src/assets/home.png b/frontend/src/assets/home.png deleted file mode 100644 index c40c6b2..0000000 Binary files a/frontend/src/assets/home.png and /dev/null differ diff --git a/frontend/src/assets/pie-chart.png b/frontend/src/assets/pie-chart.png deleted file mode 100644 index 376d9e9..0000000 Binary files a/frontend/src/assets/pie-chart.png and /dev/null differ diff --git a/frontend/src/assets/planning.png b/frontend/src/assets/planning.png deleted file mode 100644 index 13a6bb2..0000000 Binary files a/frontend/src/assets/planning.png and /dev/null differ diff --git a/frontend/src/assets/plus.png b/frontend/src/assets/plus.png deleted file mode 100644 index a5252f5..0000000 Binary files a/frontend/src/assets/plus.png and /dev/null differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/EisenhowerMatrix/Eisenhower.jsx b/frontend/src/components/EisenhowerMatrix/Eisenhower.jsx index 7800fa9..cc19aaa 100644 --- a/frontend/src/components/EisenhowerMatrix/Eisenhower.jsx +++ b/frontend/src/components/EisenhowerMatrix/Eisenhower.jsx @@ -1,30 +1,110 @@ -import React from 'react'; +import { useState, useEffect } from "react"; +import { FiAlertCircle, FiClock, FiXCircle, FiCheckCircle } from "react-icons/fi"; +import { readTodoTasks } from "../../api/TaskApi"; +import { axiosInstance } from "src/api/AxiosConfig"; + +function EachBlog({ name, colorCode, contentList, icon }) { + const [tasks, setTasks] = useState(contentList); + + const handleCheckboxChange = async (index) => { + try { + setTasks(contentList); + + const updatedTasks = [...tasks]; + const taskId = updatedTasks[index].id; + + const response = await axiosInstance.patch(`todo/${taskId}/`, { + completed: !updatedTasks[index].completed, + }); + + updatedTasks[index].completed = response.data.completed; + + setTasks(updatedTasks); + } catch (error) { + console.error("Error updating task:", error); + } + }; -function EachBlog({ name, colorCode }) { return ( -
-
- {name} +
+
+ {icon} + {name}
-
- Content goes here +
+
+ {contentList && contentList.length > 0 ? ( + contentList.map((item, index) => ( +
+ handleCheckboxChange(index)} + /> + +
+ )) + ) : ( +

No tasks

+ )}
); } -function Eisenhower() { +export function Eisenhower() { + const [tasks, setTasks] = useState([]); + + useEffect(() => { + readTodoTasks() + .then((data) => { + console.log(data); + const contentList_ui = data.filter((task) => task.priority === 1); + const contentList_uni = data.filter((task) => task.priority === 2); + const contentList_nui = data.filter((task) => task.priority === 3); + const contentList_nuni = data.filter((task) => task.priority === 4); + + setTasks({ + contentList_ui, + contentList_uni, + contentList_nui, + contentList_nuni, + }); + }) + .catch((error) => console.error("Error fetching tasks:", error)); + }, []); + return ( -
-

The Eisenhower Matrix

-
- - - - +
+
+ } + contentList={tasks.contentList_ui} + /> + } + contentList={tasks.contentList_uni} + /> + } + contentList={tasks.contentList_nui} + /> + } + contentList={tasks.contentList_nuni} + />
); } - -export default Eisenhower; diff --git a/frontend/src/components/FlaotingParticles.jsx b/frontend/src/components/FlaotingParticles.jsx new file mode 100644 index 0000000..e621f55 --- /dev/null +++ b/frontend/src/components/FlaotingParticles.jsx @@ -0,0 +1,84 @@ +import { useCallback } from "react"; +import Particles from "react-tsparticles"; +import { loadFull } from "tsparticles"; + +export function FloatingParticles() { + const particlesInit = useCallback(async (engine) => { + await loadFull(engine); + }, []); + + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/Home.jsx b/frontend/src/components/Home.jsx deleted file mode 100644 index 3089df5..0000000 --- a/frontend/src/components/Home.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -function HomePage() { - return ( -
-

Welcome to My Website

-
- ); -} - -export default HomePage; diff --git a/frontend/src/components/authentication/IsAuthenticated.jsx b/frontend/src/components/authentication/IsAuthenticated.jsx deleted file mode 100644 index 48322de..0000000 --- a/frontend/src/components/authentication/IsAuthenticated.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useState, useEffect } from 'react'; - -function IsAuthenticated() { - const [isAuthenticated, setIsAuthenticated] = useState(false); - - useEffect(() => { - const access_token = localStorage.getItem('access_token'); - - if (access_token) { - setIsAuthenticated(true); - } else { - setIsAuthenticated(false); - } - }, []); - - return isAuthenticated; -} - -export default IsAuthenticated; \ No newline at end of file diff --git a/frontend/src/components/authentication/LoginPage.jsx b/frontend/src/components/authentication/LoginPage.jsx index 6bba8f8..28dd0a1 100644 --- a/frontend/src/components/authentication/LoginPage.jsx +++ b/frontend/src/components/authentication/LoginPage.jsx @@ -1,141 +1,153 @@ -import React, { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useState } from "react"; +import { useNavigate, redirect } from "react-router-dom"; import { useGoogleLogin } from "@react-oauth/google"; +import { FcGoogle } from "react-icons/fc"; +import { useAuth } from "src/hooks/AuthHooks"; +import { FloatingParticles } from "../FlaotingParticles"; +import { NavPreLogin } from "../navigations/NavPreLogin"; +import { apiUserLogin, googleLogin } from "src/api/AuthenticationApi"; -import refreshAccessToken from "./refreshAcesstoken"; -import axiosapi from "../../api/AuthenticationApi"; - -function LoginPage() { +export function LoginPage() { + const { setIsAuthenticated } = useAuth(); const Navigate = useNavigate(); - useEffect(() => { - if (!refreshAccessToken()) { - Navigate("/"); - } - }, []); - const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [error, setError] = useState(null); - const handleEmailChange = event => { + const handleEmailChange = (event) => { setEmail(event.target.value); }; - const handlePasswordChange = event => { + const handlePasswordChange = (event) => { setPassword(event.target.value); }; - const handleSubmit = event => { + const handleSubmit = (event) => { event.preventDefault(); // Send a POST request to the authentication API - axiosapi - .apiUserLogin({ - email: email, - password: password, - }) - .then(res => { - // On successful login, store tokens and set the authorization header + apiUserLogin({ + email: email, + password: password, + }) + .then((res) => { localStorage.setItem("access_token", res.data.access); localStorage.setItem("refresh_token", res.data.refresh); - axiosapi.axiosInstance.defaults.headers["Authorization"] = "Bearer " + res.data.access; - Navigate("/"); + setIsAuthenticated(true); + redirect("/"); }) - .catch(err => { - console.log("Login failed"); - console.log(err); + .catch((err) => { + setError("Incorrect username or password"); }); }; const googleLoginImplicit = useGoogleLogin({ flow: "auth-code", redirect_uri: "postmessage", - onSuccess: async response => { + onSuccess: async (response) => { try { - const loginResponse = await axiosapi.googleLogin(response.code); + const loginResponse = await googleLogin(response.code); if (loginResponse && loginResponse.data) { const { access_token, refresh_token } = loginResponse.data; localStorage.setItem("access_token", access_token); localStorage.setItem("refresh_token", refresh_token); + setIsAuthenticated(true); Navigate("/"); } } catch (error) { console.error("Error with the POST request:", error); } }, - onError: errorResponse => console.log(errorResponse), + onError: (errorResponse) => console.log(errorResponse), }); return ( -
- {/* Left Section (Login Box) */} -
-
-

Log in to your account

- {/* Email Input */} -
- - -
- {/* Password Input */} -
- - -
- {/* Login Button */} - -
OR
- {/* Login with Google Button */} - - {/* Forgot Password Link */} - -
-
+
+ +
+ {/* Particles Container */} - {/* Right Section (Blurred Image Background) */} -
-
- -
- Text Overlay + + {/* Login Box */} +
+
+

Log in to your account

+ {/* Error Message */} + {error && ( +
+ + + + {error} +
+ )} + {/* Email Input */} +
+ + +
+ {/* Password Input */} +
+ + +
+ {/* Login Button */} + +
OR
+ {/* Login with Google Button */} + +
); } - -export default LoginPage; diff --git a/frontend/src/components/authentication/SignUpPage.jsx b/frontend/src/components/authentication/SignUpPage.jsx index 2712f97..6fcd086 100644 --- a/frontend/src/components/authentication/SignUpPage.jsx +++ b/frontend/src/components/authentication/SignUpPage.jsx @@ -1,151 +1,162 @@ -import React, { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import axiosapi from '../../api/AuthenticationApi'; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { FcGoogle } from "react-icons/fc"; +import { useGoogleLogin } from "@react-oauth/google"; +import { NavPreLogin } from "../navigations/NavPreLogin"; +import { useAuth } from "src/hooks/AuthHooks"; +import { createUser, googleLogin } from "src/api/AuthenticationApi"; +import { FloatingParticles } from "../FlaotingParticles"; -import Avatar from '@mui/material/Avatar'; -import Button from '@mui/material/Button'; -import CssBaseline from '@mui/material/CssBaseline'; -import TextField from '@mui/material/TextField'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Checkbox from '@mui/material/Checkbox'; -import Link from '@mui/material/Link'; -import Grid from '@mui/material/Grid'; -import Box from '@mui/material/Box'; -import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; -import Typography from '@mui/material/Typography'; -import Container from '@mui/material/Container'; -import { createTheme, ThemeProvider } from '@mui/material/styles'; +export function SignUp() { + const Navigate = useNavigate(); + const { setIsAuthenticated } = useAuth(); + const [formData, setFormData] = useState({ + email: "", + username: "", + password: "", + }); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsSubmitting(true); + setError(null); + + const delay = (ms) => new Promise((res) => setTimeout(res, ms)); + + try { + const data = await createUser(formData); + localStorage.setItem("access_token", data.access_token); + localStorage.setItem("refresh_token", data.refresh_token); + await delay(200); + setIsAuthenticated(true); + Navigate("/"); + } catch (error) { + console.error("Error creating user:", error); + setError("Registration failed. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleEmailChange = (e) => { + setFormData({ ...formData, email: e.target.value }); + }; + + const handleUsernameChange = (e) => { + setFormData({ ...formData, username: e.target.value }); + }; + + const handlePasswordChange = (e) => { + setFormData({ ...formData, password: e.target.value }); + }; + + const googleLoginImplicit = useGoogleLogin({ + flow: "auth-code", + redirect_uri: "postmessage", + scope: + "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/calendar.acls.readonly https://www.googleapis.com/auth/calendar.events.readonly", + onSuccess: async (response) => { + try { + const loginResponse = await googleLogin(response.code); + if (loginResponse && loginResponse.data) { + const { access_token, refresh_token } = loginResponse.data; + + localStorage.setItem("access_token", access_token); + localStorage.setItem("refresh_token", refresh_token); + setIsAuthenticated(true); + Navigate("/profile"); + } + } catch (error) { + console.error("Error with the POST request:", error); + setIsAuthenticated(false); + } + }, + onError: (errorResponse) => console.log(errorResponse), + }); -function Copyright(props) { return ( - - {'Copyright © '} - - TurTask - {' '} - {new Date().getFullYear()} - {'.'} - +
+ +
+ +
+
+ {/* Register Form */} +

Signup

+ {/* Email Input */} +
+ + +
+ {/* Username Input */} +
+ + +
+ {/* Password Input */} +
+ + +
+

+ + {/* Signups Button */} + +
OR
+ {/* Login with Google Button */} + + {/* Already have an account? */} + +
+
+
+
); } - -const defaultTheme = createTheme(); - -export default function SignUp() { - - const Navigate = useNavigate(); - - const [formData, setFormData] = useState({ - email: '', - username: '', - password: '', - }); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e) => { - e.preventDefault(); - setIsSubmitting(true); - setError(null); - - try { - axiosapi.createUser(formData); - } catch (error) { - console.error('Error creating user:', error); - setError('Registration failed. Please try again.'); - } finally { - setIsSubmitting(false); - } - Navigate('/login'); - }; - - const handleChange = (e) => { - const { name, value } = e.target; - setFormData({ ...formData, [name]: value }); - }; - - return ( - - - - - - - - - Sign up - - - - - - - - - - - - - - } - label="I want to receive inspiration, marketing promotions and updates via email." - /> - - - - - - - Already have an account? Sign in - - - - - - - - - ); -} \ No newline at end of file diff --git a/frontend/src/components/authentication/refreshAcessToken.jsx b/frontend/src/components/authentication/refreshAcessToken.jsx deleted file mode 100644 index 89204d5..0000000 --- a/frontend/src/components/authentication/refreshAcessToken.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import axios from 'axios'; - -async function refreshAccessToken() { - const refresh_token = localStorage.getItem('refresh_token'); - const access_token = localStorage.getItem('access_token'); - - if (access_token) { - return true; - } - - if (!refresh_token) { - return false; - } - - const refreshUrl = 'http://127.0.0.1:8000/api/token/refresh/'; - - try { - const response = await axios.post(refreshUrl, { refresh: refresh_token }); - - if (response.status === 200) { - // Successful refresh - save the new access token and refresh token - const newAccessToken = response.data.access; - const newRefreshToken = response.data.refresh; - - localStorage.setItem('access_token', newAccessToken); - localStorage.setItem('refresh_token', newRefreshToken); - - return true; - } else { - return false; - } - } catch (error) { - return false; - } -} - -export default refreshAccessToken; diff --git a/frontend/src/components/calendar/TaskDataHandler.jsx b/frontend/src/components/calendar/TaskDataHandler.jsx index 3c123e9..793ca4b 100644 --- a/frontend/src/components/calendar/TaskDataHandler.jsx +++ b/frontend/src/components/calendar/TaskDataHandler.jsx @@ -1,42 +1,26 @@ -import { fetchTodoTasks } from '../../api/TaskApi'; +import { readTodoTasks } from "src/api/TaskApi"; -let eventGuid = 0 - -// function getDateAndTime(dateString) { -// const dateObject = new Date(dateString); - -// const year = dateObject.getFullYear(); -// const month = (dateObject.getMonth() + 1).toString().padStart(2, '0'); -// const day = dateObject.getDate().toString().padStart(2, '0'); -// const dateFormatted = `${year}-${month}-${day}`; - -// const hours = dateObject.getUTCHours().toString().padStart(2, '0'); -// const minutes = dateObject.getUTCMinutes().toString().padStart(2, '0'); -// const seconds = dateObject.getUTCSeconds().toString().padStart(2, '0'); -// const timeFormatted = `T${hours}:${minutes}:${seconds}`; - -// return dateFormatted + timeFormatted; -// } +let eventGuid = 0; const mapResponseToEvents = (response) => { - return response.map(item => ({ - id: createEventId(), - title: item.title, - start: item.start_event, - end: item.end_event, - })); -} + return response.map((item) => ({ + id: item.id, + title: item.title, + start: item.start_event, + end: item.end_event, + })); +}; export async function getEvents() { - try { - const response = await fetchTodoTasks(); - return mapResponseToEvents(response); - } catch (error) { - console.error(error); - return []; - } + try { + const response = await readTodoTasks(); + return mapResponseToEvents(response); + } catch (error) { + console.error(error); + return []; + } } export function createEventId() { - return String(eventGuid++); -} \ No newline at end of file + return String(eventGuid++); +} diff --git a/frontend/src/components/calendar/calendar.jsx b/frontend/src/components/calendar/calendar.jsx index 6258da9..79dae97 100644 --- a/frontend/src/components/calendar/calendar.jsx +++ b/frontend/src/components/calendar/calendar.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState } from "react"; import { formatDate } from "@fullcalendar/core"; import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; @@ -6,7 +6,7 @@ import timeGridPlugin from "@fullcalendar/timegrid"; import interactionPlugin from "@fullcalendar/interaction"; import { getEvents, createEventId } from "./TaskDataHandler"; -export default class Calendar extends React.Component { +export class Calendar extends React.Component { state = { weekendsVisible: true, currentEvents: [], @@ -43,7 +43,8 @@ export default class Calendar extends React.Component { renderSidebar() { return ( -
+
+ {/* Description Zone */}

Instructions

    @@ -53,19 +54,24 @@ export default class Calendar extends React.Component {
+ {/* Toggle */}
+
-
+ {/* Show all task */} +

All Events ({this.state.currentEvents.length})

    {this.state.currentEvents.map(renderSidebarEvent)}
@@ -98,7 +104,14 @@ export default class Calendar extends React.Component { handleEventClick = (clickInfo) => { if (confirm(`Are you sure you want to delete the event '${clickInfo.event.title}'`)) { - clickInfo.event.remove(); + axiosInstance + .delete(`todo/${clickInfo.event.id}/`) + .then((response) => { + clickInfo.event.remove(); + }) + .catch((error) => { + console.error("Error deleting Task:", error); + }); } }; diff --git a/frontend/src/components/dashboard/Areachart.jsx b/frontend/src/components/dashboard/Areachart.jsx new file mode 100644 index 0000000..cd6bb93 --- /dev/null +++ b/frontend/src/components/dashboard/Areachart.jsx @@ -0,0 +1,36 @@ +import { AreaChart, Title } from "@tremor/react"; +import { useState, useEffect } from "react"; +import { axiosInstance } from "src/api/AxiosConfig"; + +export const AreaChartGraph = () => { + const [areaChartDataArray, setAreaChartDataArray] = useState([]); + + useEffect(() => { + const fetchAreaChartData = async () => { + try { + const response = await axiosInstance.get("/dashboard/weekly/"); + const areaChartData = response.data; + setAreaChartDataArray(areaChartData); + } catch (error) { + console.error("Error fetching area chart data:", error); + } + }; + + fetchAreaChartData(); + }, []); + + return ( + <> + Number of tasks statistics vs. last week + + + ); +}; diff --git a/frontend/src/components/dashboard/Barchart.jsx b/frontend/src/components/dashboard/Barchart.jsx new file mode 100644 index 0000000..bf84a2b --- /dev/null +++ b/frontend/src/components/dashboard/Barchart.jsx @@ -0,0 +1,36 @@ +import { BarChart, Title } from "@tremor/react"; +import { useState, useEffect } from "react"; +import { axiosInstance } from "src/api/AxiosConfig"; + +export const BarChartGraph = () => { + const [barchartDataArray, setBarChartDataArray] = useState([]); + + useEffect(() => { + const fetchAreaChartData = async () => { + try { + const response = await axiosInstance.get("/dashboard/weekly/"); + const barchartDataArray = response.data; + setBarChartDataArray(barchartDataArray); + } catch (error) { + console.error("Error fetching area chart data:", error); + } + }; + + fetchAreaChartData(); + }, []); + + return ( + <> + Task completed statistics vs. last week + + + ); +}; diff --git a/frontend/src/components/dashboard/DonutChart.jsx b/frontend/src/components/dashboard/DonutChart.jsx new file mode 100644 index 0000000..63fc591 --- /dev/null +++ b/frontend/src/components/dashboard/DonutChart.jsx @@ -0,0 +1,39 @@ +import { DonutChart } from "@tremor/react"; +import { axiosInstance } from "src/api/AxiosConfig"; +import { useState, useEffect } from "react"; + +export function DonutChartGraph() { + const [donutData, setDonutData] = useState([]); + + useEffect(() => { + const fetchDonutData = async () => { + try { + const response = await axiosInstance.get("/dashboard/stats/"); + const todoCount = response.data.todo_count || 0; + const recurrenceCount = response.data.recurrence_count || 0; + + const donutData = [ + { name: "Todo", count: todoCount }, + { name: "Recurrence", count: recurrenceCount }, + ]; + + setDonutData(donutData); + } catch (error) { + console.error("Error fetching donut data:", error); + } + }; + fetchDonutData(); + }, []); + + return ( + + ); +} diff --git a/frontend/src/components/dashboard/KpiCard.jsx b/frontend/src/components/dashboard/KpiCard.jsx new file mode 100644 index 0000000..c5c3165 --- /dev/null +++ b/frontend/src/components/dashboard/KpiCard.jsx @@ -0,0 +1,57 @@ +import { BadgeDelta, Card, Flex, Metric, ProgressBar, Text } from "@tremor/react"; +import { useEffect, useState } from "react"; +import { axiosInstance } from "src/api/AxiosConfig"; + +export function KpiCard() { + const [kpiCardData, setKpiCardData] = useState({ + completedThisWeek: 0, + completedLastWeek: 0, + incOrdec: undefined, + percentage: 0, + }); + + useEffect(() => { + const fetchKpiCardData = async () => { + try { + const response = await axiosInstance.get("/dashboard/stats/"); + const completedThisWeek = response.data.completed_this_week || 0; + const completedLastWeek = response.data.completed_last_week || 0; + const percentage = (completedThisWeek / completedLastWeek) * 100; + let incOrdec = undefined; + + if (completedThisWeek <= completedLastWeek) { + incOrdec = "moderateDecrease"; + } + if (completedThisWeek > completedLastWeek) { + incOrdec = "moderateIncrease"; + } + + setKpiCardData({ + completedThisWeek, + completedLastWeek, + incOrdec, + percentage, + }); + } catch (error) { + console.error("Error fetching KPI card data:", error); + } + }; + + fetchKpiCardData(); + }, []); + + return ( + + +
+ {kpiCardData.completedThisWeek} +
+ {kpiCardData.percentage.toFixed(0)}% +
+ + vs. {kpiCardData.completedLastWeek} (last week) + + +
+ ); +} diff --git a/frontend/src/components/dashboard/ProgressCircle.jsx b/frontend/src/components/dashboard/ProgressCircle.jsx new file mode 100644 index 0000000..683781e --- /dev/null +++ b/frontend/src/components/dashboard/ProgressCircle.jsx @@ -0,0 +1,44 @@ +import { Card, Flex, ProgressCircle } from "@tremor/react"; +import { useState, useEffect } from "react"; +import { axiosInstance } from "src/api/AxiosConfig"; + +export function ProgressCircleChart() { + const [progressData, setProgressData] = useState(0); + + useEffect(() => { + const fetchProgressData = async () => { + try { + const response = await axiosInstance.get("/dashboard/stats/"); + let completedLastWeek = response.data.completed_last_week || 0; + let assignLastWeek = response.data.tasks_assigned_last_week || 0; + + if (completedLastWeek === undefined) { + completedLastWeek = 0; + } + if (assignLastWeek === undefined) { + assignLastWeek = 0; + } + + const progress = (completedLastWeek / assignLastWeek) * 100; + + setProgressData(progress); + } catch (error) { + console.error("Error fetching progress data:", error); + } + }; + + fetchProgressData(); + }, []); + + return ( + + + + + {progressData.toFixed(0)} % + + + + + ); +} diff --git a/frontend/src/components/dashboard/dashboard.jsx b/frontend/src/components/dashboard/dashboard.jsx new file mode 100644 index 0000000..31cc71f --- /dev/null +++ b/frontend/src/components/dashboard/dashboard.jsx @@ -0,0 +1,72 @@ +import { Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title, Legend } from "@tremor/react"; +import { KpiCard } from "./KpiCard"; +import { BarChartGraph } from "./Barchart"; +import { DonutChartGraph } from "./DonutChart"; +import { AreaChartGraph } from "./Areachart"; +import { ProgressCircleChart } from "./ProgressCircle"; +import { useState } from "react"; + +export function Dashboard() { + const [value, setValue] = useState({ + from: new Date(2021, 0, 1), + to: new Date(2023, 0, 7), + }); + return ( +
+
+ Dashboard + All of your progress will be shown right here. +
+
+ +
+ + + Weekly + Overview + + + {/*Weekly Tab*/} + + + + Highlights vs. last week +
+ +
+ Last week progress rate +
+ + +
+ + + + + + +
+
+ +
+ + Tasks + +
+ +
+
+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/icons/plusIcon.jsx b/frontend/src/components/icons/plusIcon.jsx deleted file mode 100644 index 080d990..0000000 --- a/frontend/src/components/icons/plusIcon.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -function PlusIcon() { - return ( - - - - ); -} - -export default PlusIcon; diff --git a/frontend/src/components/icons/trashIcon.jsx b/frontend/src/components/icons/trashIcon.jsx deleted file mode 100644 index f0be9fc..0000000 --- a/frontend/src/components/icons/trashIcon.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; - -function TrashIcon() { - return ( - React.createElement( - "svg", - { - xmlns: "http://www.w3.org/2000/svg", - fill: "none", - viewBox: "0 0 24 24", - strokeWidth: 1.5, - className: "w-6 h-6" - }, - React.createElement("path", { - strokeLinecap: "round", - strokeLinejoin: "round", - d: "M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" - }) - ) - ); -} - -export default TrashIcon; diff --git a/frontend/src/components/kanbanBoard/columnContainer.jsx b/frontend/src/components/kanbanBoard/columnContainer.jsx index a72d7e3..d6f4565 100644 --- a/frontend/src/components/kanbanBoard/columnContainer.jsx +++ b/frontend/src/components/kanbanBoard/columnContainer.jsx @@ -1,173 +1,64 @@ import { SortableContext, useSortable } from "@dnd-kit/sortable"; -import TrashIcon from "../icons/trashIcon"; -import { CSS } from "@dnd-kit/utilities"; -import { useMemo, useState } from "react"; -import PlusIcon from "../icons/plusIcon"; -import TaskCard from "./taskCard"; - -function ColumnContainer({ - column, - deleteColumn, - updateColumn, - createTask, - tasks, - deleteTask, - updateTask, -}) { - const [editMode, setEditMode] = useState(false); +import { AiOutlinePlusCircle } from "react-icons/ai"; +import { useMemo } from "react"; +import { TaskCard } from "./taskCard"; +export function ColumnContainer({ column, createTask, tasks, deleteTask, updateTask }) { + // Memoize task IDs to prevent unnecessary recalculations const tasksIds = useMemo(() => { return tasks.map((task) => task.id); }, [tasks]); - const { - setNodeRef, - attributes, - listeners, - transform, - transition, - isDragging, - } = useSortable({ - id: column.id, - data: { - type: "Column", - column, - }, - disabled: editMode, - }); - - const style = { - transition, - transform: CSS.Transform.toString(transform), - }; - - if (isDragging) { - return ( -
- ); - } - return (
+ "> {/* Column title */}
{ - setEditMode(true); - }} className=" - bg-mainBackgroundColor + ml-3 text-md - h-[60px] - cursor-grab - rounded-md - rounded-b-none - p-3 font-bold - border-columnBackgroundColor - border-4 flex items-center justify-between - " - > -
-
- {!editMode && column.title} - {editMode && ( - updateColumn(column.id, e.target.value)} - autoFocus - onBlur={() => { - setEditMode(false); - }} - onKeyDown={(e) => { - if (e.key !== "Enter") return; - setEditMode(false); - }} - /> - )} -
- + "> +
{column.title}
{/* Column task container */} -
+
+ {/* Provide a SortableContext for the tasks within the column */} + {/* Render TaskCard for each task in the column */} {tasks.map((task) => ( useSortable({ ...props, disabled: false })} /> ))}
+ {/* Column footer */}
); } - -export default ColumnContainer; diff --git a/frontend/src/components/kanbanBoard/columnContainerWrapper.jsx b/frontend/src/components/kanbanBoard/columnContainerWrapper.jsx new file mode 100644 index 0000000..40d90af --- /dev/null +++ b/frontend/src/components/kanbanBoard/columnContainerWrapper.jsx @@ -0,0 +1,15 @@ +import { ColumnContainer } from "./columnContainer"; + +export function ColumnContainerCard({ column, createTask, tasks, deleteTask, updateTask }) { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/kanbanBoard/kanbanBoard.jsx b/frontend/src/components/kanbanBoard/kanbanBoard.jsx index b50f354..2bda08b 100644 --- a/frontend/src/components/kanbanBoard/kanbanBoard.jsx +++ b/frontend/src/components/kanbanBoard/kanbanBoard.jsx @@ -1,110 +1,20 @@ -import PlusIcon from "../icons/plusIcon"; -import { useMemo, useState } from "react"; -import ColumnContainer from "./columnContainer"; -import { - DndContext, - DragOverlay, - PointerSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; +import { useMemo, useState, useEffect } from "react"; +import { ColumnContainerCard } from "./columnContainerWrapper"; +import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { SortableContext, arrayMove } from "@dnd-kit/sortable"; import { createPortal } from "react-dom"; -import TaskCard from "./taskCard"; +import { TaskCard } from "./taskCard"; +import { axiosInstance } from "src/api/AxiosConfig"; -const defaultCols = [ - { - id: "todo", - title: "Todo", - }, - { - id: "doing", - title: "Work in progress", - }, - { - id: "done", - title: "Done", - }, -]; - -const defaultTasks = [ - { - id: "1", - columnId: "todo", - content: "List admin APIs for dashboard", - }, - { - id: "2", - columnId: "todo", - content: - "Develop user registration functionality with OTP delivered on SMS after email confirmation and phone number confirmation", - }, - { - id: "3", - columnId: "doing", - content: "Conduct security testing", - }, - { - id: "4", - columnId: "doing", - content: "Analyze competitors", - }, - { - id: "5", - columnId: "done", - content: "Create UI kit documentation", - }, - { - id: "6", - columnId: "done", - content: "Dev meeting", - }, - { - id: "7", - columnId: "done", - content: "Deliver dashboard prototype", - }, - { - id: "8", - columnId: "todo", - content: "Optimize application performance", - }, - { - id: "9", - columnId: "todo", - content: "Implement data validation", - }, - { - id: "10", - columnId: "todo", - content: "Design database schema", - }, - { - id: "11", - columnId: "todo", - content: "Integrate SSL web certificates into workflow", - }, - { - id: "12", - columnId: "doing", - content: "Implement error logging and monitoring", - }, - { - id: "13", - columnId: "doing", - content: "Design and implement responsive UI", - }, -]; - -function KanbanBoard() { - const [columns, setColumns] = useState(defaultCols); +export function KanbanBoard() { + const [columns, setColumns] = useState([]); + const [boardId, setBoardData] = useState(); + const [isLoading, setLoading] = useState(false); + const [tasks, setTasks] = useState([]); + const [activeTask, setActiveTask] = useState(null); const columnsId = useMemo(() => columns.map((col) => col.id), [columns]); - const [tasks, setTasks] = useState(defaultTasks); - - const [activeColumn, setActiveColumn] = useState(null); - - const [activeTask, setActiveTask] = useState(null); + // ---------------- END STATE INITIATE ---------------- const sensors = useSensors( useSensor(PointerSensor, { @@ -114,88 +24,177 @@ function KanbanBoard() { }) ); + // ---------------- Task Handlers ---------------- + const handleTaskUpdate = (tasks, updatedTask) => { + const updatedTasks = tasks.map((task) => (task.id === updatedTask.id ? updatedTask : task)); + setTasks(updatedTasks); + }; + + const handleApiError = (error, action) => { + console.error(`Error ${action}:`, error); + }; + + const createTask = async (columnId) => { + try { + const response = await axiosInstance.post("todo/", { + title: `New Task`, + importance: 1, + difficulty: 1, + challenge: false, + fromSystem: false, + is_active: false, + is_full_day_event: false, + completed: false, + priority: 1, + list_board: columnId, + }); + const newTask = { + id: response.data.id, + columnId, + content: response.data.title, + }; + + setTasks((prevTasks) => [...prevTasks, newTask]); + } catch (error) { + handleApiError(error, "creating task"); + } + }; + + const deleteTask = async (id) => { + try { + await axiosInstance.delete(`todo/${id}/`); + const newTasks = tasks.filter((task) => task.id !== id); + setTasks(newTasks); + } catch (error) { + handleApiError(error, "deleting task"); + } + }; + + const updateTask = async (id, content, tasks) => { + try { + if (content === "") { + await deleteTask(id); + } else { + const response = await axiosInstance.put(`todo/${id}/`, { content }); + + const updatedTask = { + id, + columnId: response.data.list_board, + content: response.data.title, + }; + + handleTaskUpdate(tasks, updatedTask); + } + } catch (error) { + handleApiError(error, "updating task"); + } + }; + + // ---------------- END Task Handlers ---------------- + + // ---------------- Fetch Data ---------------- + useEffect(() => { + const fetchData = async () => { + try { + const tasksResponse = await axiosInstance.get("/todo"); + + // Transform + const transformedTasks = tasksResponse.data.map((task) => ({ + id: task.id, + columnId: task.list_board, + content: task.title, + difficulty: task.difficulty, + notes: task.notes, + importance: task.importance, + challenge: task.challenge, + fromSystem: task.fromSystem, + creation_date: task.creation_date, + last_update: task.last_update, + is_active: task.is_active, + is_full_day_event: task.is_full_day_event, + start_event: task.start_event, + end_event: task.end_event, + google_calendar_id: task.google_calendar_id, + completed: task.completed, + completion_date: task.completion_date, + priority: task.priority, + user: task.user, + list_board: task.list_board, + tags: task.tags, + })); + setTasks(transformedTasks); + + const columnsResponse = await axiosInstance.get("/lists"); + + // Transform + const transformedColumns = columnsResponse.data.map((column) => ({ + id: column.id, + title: column.name, + })); + setColumns(transformedColumns); + } catch (error) { + console.error("Error fetching data from API:", error); + } + }; + fetchData(); + }, []); + + useEffect(() => { + const fetchBoardData = async () => { + try { + setLoading(true); + const response = await axiosInstance.get("boards/"); + if (response.data && response.data.length > 0) { + setBoardData(response.data[0]); + } + } catch (error) { + console.error("Error fetching board data:", error); + setLoading(false); + } + setLoading(false); + }; + fetchBoardData(); + }, []); + + // ---------------- END Fetch Data ---------------- + return (
- -
-
- - {columns.map((col) => ( - task.columnId === col.id)} - /> - ))} - -
- {/* create new column */} - + w-full + items-center + justify-center + overflow-x-auto + overflow-y-hidden + "> + +
+
+ {!isLoading ? ( + + {columns.map((col) => ( + task.columnId === col.id)} + /> + ))}{" "} + + ) : ( + + )} +
{createPortal( - - {activeColumn && ( - task.columnId === activeColumn.id - )} - /> - )} - {activeTask && ( - - )} + + {/* Render the active task as a draggable overlay */} + , document.body )} @@ -203,135 +202,122 @@ function KanbanBoard() {
); - function createTask(columnId) { - const newTask = { - id: generateId(), - columnId, - content: `Task ${tasks.length + 1}`, - }; - - setTasks([...tasks, newTask]); - } - - function deleteTask(id) { - const newTasks = tasks.filter((task) => task.id !== id); - setTasks(newTasks); - } - - function updateTask(id, content) { - const newTasks = tasks.map((task) => { - if (task.id !== id) return task; - return { ...task, content }; - }); - if (content === "") return deleteTask(id); - setTasks(newTasks); - } - - function createNewColumn() { - const columnToAdd = { - id: generateId(), - title: `Column ${columns.length + 1}`, - }; - - setColumns([...columns, columnToAdd]); - } - - function deleteColumn(id) { - const filteredColumns = columns.filter((col) => col.id !== id); - setColumns(filteredColumns); - - const newTasks = tasks.filter((t) => t.columnId !== id); - setTasks(newTasks); - } - - function updateColumn(id, title) { - const newColumns = columns.map((col) => { - if (col.id !== id) return col; - return { ...col, title }; - }); - - setColumns(newColumns); - } - + // Handle the start of a drag event function onDragStart(event) { - if (event.active.data.current?.type === "Column") { - setActiveColumn(event.active.data.current.column); - return; - } - + // Check if the dragged item is a Task if (event.active.data.current?.type === "Task") { setActiveTask(event.active.data.current.task); return; } } + // Handle the end of a drag event function onDragEnd(event) { - setActiveColumn(null); + // Reset active column and task after the drag ends setActiveTask(null); const { active, over } = event; - if (!over) return; + if (!over) return; // If not dropped over anything, exit const activeId = active.id; const overId = over.id; - if (activeId === overId) return; - - const isActiveAColumn = active.data.current?.type === "Column"; - if (!isActiveAColumn) return; - - setColumns((columns) => { - const activeColumnIndex = columns.findIndex((col) => col.id === activeId); - - const overColumnIndex = columns.findIndex((col) => col.id === overId); - - return arrayMove(columns, activeColumnIndex, overColumnIndex); - }); - } - - function onDragOver(event) { - const { active, over } = event; - if (!over) return; - - const activeId = active.id; - const overId = over.id; - - if (activeId === overId) return; - const isActiveATask = active.data.current?.type === "Task"; - const isOverATask = over.data.current?.type === "Task"; - - if (!isActiveATask) return; - - if (isActiveATask && isOverATask) { - setTasks((tasks) => { - const activeIndex = tasks.findIndex((t) => t.id === activeId); - const overIndex = tasks.findIndex((t) => t.id === overId); - - if (tasks[activeIndex].columnId !== tasks[overIndex].columnId) { - tasks[activeIndex].columnId = tasks[overIndex].columnId; - return arrayMove(tasks, activeIndex, overIndex - 1); - } - - return arrayMove(tasks, activeIndex, overIndex); - }); - } - const isOverAColumn = over.data.current?.type === "Column"; + // Move tasks between columns and update columnId if (isActiveATask && isOverAColumn) { setTasks((tasks) => { const activeIndex = tasks.findIndex((t) => t.id === activeId); - tasks[activeIndex].columnId = overId; + // Extract the column ID from overId + const columnId = extractColumnId(overId); + + tasks[activeIndex].columnId = columnId; + + // API call to update task's columnId + axiosInstance + .put(`todo/change_task_list_board/`, { + todo_id: activeId, + new_list_board_id: over.data.current.task.columnId, + new_index: 0, + }) + .then((response) => {}) + .catch((error) => { + console.error("Error updating task columnId:", error); + }); + return arrayMove(tasks, activeIndex, activeIndex); }); } } - function generateId() { - return Math.floor(Math.random() * 10001); + // Helper function to extract the column ID from the element ID + function extractColumnId(elementId) { + // Implement logic to extract the column ID from elementId + // For example, if elementId is in the format "column-123", you can do: + const parts = elementId.split("-"); + return parts.length === 2 ? parseInt(parts[1], 10) : null; + } + + // Handle the drag-over event + function onDragOver(event) { + const { active, over } = event; + if (!over) return; // If not over anything, exit + + const activeId = active.id; + const overId = over.id; + + if (activeId === overId) return; // If over the same element, exit + + const isActiveATask = active.data.current?.type === "Task"; + const isOverATask = over.data.current?.type === "Task"; + + if (!isActiveATask) return; // If not dragging a Task, exit + + // Reorder logic for Tasks within the same column + if (isActiveATask && isOverATask) { + setTasks((tasks) => { + const activeIndex = tasks.findIndex((t) => t.id === activeId); + const overIndex = tasks.findIndex((t) => t.id === overId); + + // If moving to a different column, update columnId + if (tasks[activeIndex].columnId !== tasks[overIndex].columnId) { + tasks[activeIndex].columnId = tasks[overIndex].columnId; + return arrayMove(tasks, activeIndex, overIndex - 1); + } + axiosInstance + .put(`todo/change_task_list_board/`, { + todo_id: activeId, + new_list_board_id: over.data.current.task.columnId, + new_index: 0, + }) + .then((response) => {}) + .catch((error) => { + console.error("Error updating task columnId:", error); + }); + return arrayMove(tasks, activeIndex, overIndex); + }); + } + + const isOverAColumn = over.data.current?.type === "Column"; + // Move the Task to a different column and update columnId + if (isActiveATask && isOverAColumn && tasks.some((task) => task.columnId !== overId)) { + setTasks((tasks) => { + const activeIndex = tasks.findIndex((t) => t.id === activeId); + axiosInstance + .put(`todo/change_task_list_board/`, { + todo_id: activeId, + new_list_board_id: over.data.current.task.columnId, + new_index: 0, + }) + .then((response) => {}) + .catch((error) => { + console.error("Error updating task columnId:", error); + }); + tasks[activeIndex].columnId = overId; + return arrayMove(tasks, activeIndex, activeIndex); + }); + } } } - -export default KanbanBoard; diff --git a/frontend/src/components/kanbanBoard/kanbanPage.jsx b/frontend/src/components/kanbanBoard/kanbanPage.jsx new file mode 100644 index 0000000..92a8536 --- /dev/null +++ b/frontend/src/components/kanbanBoard/kanbanPage.jsx @@ -0,0 +1,34 @@ +import { KanbanBoard } from "./kanbanBoard"; +import { useState } from "react"; + +export const KanbanPage = () => { + const [activeTab, setActiveTab] = useState("kanban"); + + const handleTabClick = (tabId) => { + setActiveTab(tabId); + }; + + return ( + + ); +}; diff --git a/frontend/src/components/kanbanBoard/taskCard.jsx b/frontend/src/components/kanbanBoard/taskCard.jsx index cc2f4a4..1a151c8 100644 --- a/frontend/src/components/kanbanBoard/taskCard.jsx +++ b/frontend/src/components/kanbanBoard/taskCard.jsx @@ -1,26 +1,18 @@ import { useState } from "react"; -import TrashIcon from "../icons/trashIcon"; +import { BsFillTrashFill } from "react-icons/bs"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { TaskDetailModal } from "./taskDetailModal"; -function TaskCard({ task, deleteTask, updateTask }) { +export function TaskCard({ task, deleteTask, updateTask }) { const [mouseIsOver, setMouseIsOver] = useState(false); - const [editMode, setEditMode] = useState(true); - const { - setNodeRef, - attributes, - listeners, - transform, - transition, - isDragging, - } = useSortable({ + const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ id: task.id, data: { type: "Task", task, }, - disabled: editMode, }); const style = { @@ -28,11 +20,9 @@ function TaskCard({ task, deleteTask, updateTask }) { transform: CSS.Transform.toString(transform), }; - const toggleEditMode = () => { - setEditMode((prev) => !prev); - setMouseIsOver(false); - }; - + { + /* If card is dragged */ + } if (isDragging) { return (
); } - if (editMode) { - return ( + return ( +
+
- +
+ + {/* Difficulty, Challenge, and Importance */} +
+
+ +
+ Easy + Normal + Hard + Very Hard + Devil +
+
+ {/* Challenge Checkbox */} +
+
+ +
+
+ + {/* Important Checkbox */} +
+
+ +
+
+
+ + {/* Subtask */} +
+

+ + + Subtasks + +

+
+ + +
+
+ +
+ +
+
+ + ); +} diff --git a/frontend/src/components/landingPage/LandingPage.jsx b/frontend/src/components/landingPage/LandingPage.jsx new file mode 100644 index 0000000..1b0bd9e --- /dev/null +++ b/frontend/src/components/landingPage/LandingPage.jsx @@ -0,0 +1,44 @@ +import { FloatingParticles } from "../FlaotingParticles"; + +export function LandingPage() { + return ( +
+ {/* Particles Container */} + + {/* Navbar */} +
+
+
+
+

+ Manage your task with{" "} + + TurTask + + +

+

+ Unleash productivity with our personal task and project + management. +

+ +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/navigations/IconSideNav.jsx b/frontend/src/components/navigations/IconSideNav.jsx index 982bc01..9f9ad4b 100644 --- a/frontend/src/components/navigations/IconSideNav.jsx +++ b/frontend/src/components/navigations/IconSideNav.jsx @@ -1,36 +1,24 @@ import { useState } from "react"; -import { - AiOutlineHome, - AiOutlineSchedule, - AiOutlineUnorderedList, - AiOutlinePieChart, - AiOutlinePlus, -} from "react-icons/ai"; +import { AiOutlineHome, AiOutlineSchedule, AiOutlineUnorderedList } from "react-icons/ai"; +import { PiStepsDuotone } from "react-icons/pi"; +import { IoSettingsOutline } from "react-icons/io5"; import { AnimatePresence, motion } from "framer-motion"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; const menuItems = [ { id: 0, path: "/", icon: }, { id: 1, path: "/tasks", icon: }, { id: 2, path: "/calendar", icon: }, - { id: 3, path: "/analytic", icon: }, - { id: 4, path: "/priority", icon: }, + { id: 3, path: "/priority", icon: }, ]; +// { id: 3, path: "/settings", icon: }, -const IconSideNav = () => { - return ( -
- -
- ); -}; - -const SideNav = () => { +export const SideNav = () => { const [selected, setSelected] = useState(0); return ( -
+
-
- -
+ {/*
+ +
*/} {isAuthenticated ? (
); } -export default NavBar; diff --git a/frontend/src/components/navigators/Navbar.jsx b/frontend/src/components/navigators/Navbar.jsx deleted file mode 100644 index 75cd8a7..0000000 --- a/frontend/src/components/navigators/Navbar.jsx +++ /dev/null @@ -1,69 +0,0 @@ -import * as React from "react"; -import { useNavigate } from "react-router-dom"; -import IsAuthenticated from "../authentication/IsAuthenticated"; -import axiosapi from "../../api/AuthenticationApi"; - -const settings = { - Profile: '/update_profile', - Account: '/account', -}; - -function NavBar() { - const Navigate = useNavigate(); - - const isAuthenticated = IsAuthenticated(); - - const logout = () => { - axiosapi.apiUserLogout(); - Navigate("/"); - }; - - return ( -
- -
-
- -
- {isAuthenticated ? ( -
- - -
- ) : ( -
- - -
- )} -
-
- ); -} -export default NavBar; diff --git a/frontend/src/components/ProfileUpdatePage.jsx b/frontend/src/components/profile/ProfileUpdateComponent.jsx similarity index 79% rename from frontend/src/components/ProfileUpdatePage.jsx rename to frontend/src/components/profile/ProfileUpdateComponent.jsx index 06a0213..12f5e98 100644 --- a/frontend/src/components/ProfileUpdatePage.jsx +++ b/frontend/src/components/profile/ProfileUpdateComponent.jsx @@ -1,12 +1,12 @@ -import React, { useState, useRef } from 'react'; -import { ApiUpdateUserProfile } from '../api/UserProfileApi'; +import { useState, useRef } from "react"; +import { ApiUpdateUserProfile } from "src/api/UserProfileApi"; -function ProfileUpdate() { +export function ProfileUpdateComponent() { const [file, setFile] = useState(null); - const [username, setUsername] = useState(''); - const [fullName, setFullName] = useState(''); - const [about, setAbout] = useState(''); - const defaultImage = 'https://i1.sndcdn.com/artworks-cTz48e4f1lxn5Ozp-L3hopw-t500x500.jpg'; + const [username, setUsername] = useState(""); + const [fullName, setFullName] = useState(""); + const [about, setAbout] = useState(""); + const defaultImage = "https://i1.sndcdn.com/artworks-cTz48e4f1lxn5Ozp-L3hopw-t500x500.jpg"; const fileInputRef = useRef(null); const handleImageUpload = () => { @@ -24,9 +24,9 @@ function ProfileUpdate() { const handleSave = () => { const formData = new FormData(); - formData.append('profile_pic', file); - formData.append('first_name', username); - formData.append('about', about); + formData.append("profile_pic", file); + formData.append("first_name", username); + formData.append("about", about); ApiUpdateUserProfile(formData); }; @@ -45,10 +45,7 @@ function ProfileUpdate() { ref={fileInputRef} /> -
+
{file ? ( Profile ) : ( @@ -103,5 +100,3 @@ function ProfileUpdate() {
); } - -export default ProfileUpdate; diff --git a/frontend/src/components/profile/profilePage.jsx b/frontend/src/components/profile/profilePage.jsx new file mode 100644 index 0000000..d61368a --- /dev/null +++ b/frontend/src/components/profile/profilePage.jsx @@ -0,0 +1,144 @@ +import { ProfileUpdateComponent } from "./ProfileUpdateComponent"; + +export function ProfileUpdatePage() { + return ( +
+
+
+
Username
+
Sirin
+
User ID
+
+
+
+ +
+
+
+
+ +
+
Health
+
+ 234/3213 +
+ + + +
+
+
32% Remain
+ +
+ +
+
Level
+
+ 1 +
+ + + +
+
+
3213/321312321 points
+ +
+ +
+
Gold
+
+ 331412421 +
+ + + +
+
+
Top 12% of Global Ranking
+ +
+
+ +
+
+

About me

+
+ +
+
+ +
+
+
+
+

Overall Statistics

+
+
+
+
+
+
+
+

Achievements

+
+
+
+
+
+
+
+

Friends

+
+
+
+
+
+ + + + {/* Modal */} + +
+
+ + + +
+
+
+ ); +} diff --git a/frontend/src/components/signup.jsx b/frontend/src/components/signup.jsx deleted file mode 100644 index 5c1bc7b..0000000 --- a/frontend/src/components/signup.jsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useState } from 'react'; -import axiosapi from '../api/axiosapi'; -import TextField from '@material-ui/core/TextField'; -import Typography from '@material-ui/core/Typography'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import Container from '@material-ui/core/Container'; -import Button from '@material-ui/core/Button'; -import { makeStyles } from '@material-ui/core/styles'; - -const useStyles = makeStyles((theme) => ({ - // Styles for various elements - paper: { - marginTop: theme.spacing(8), - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - }, - avatar: { - margin: theme.spacing(1), - backgroundColor: theme.palette.secondary.main, - }, - form: { - width: '100%', - marginTop: theme.spacing(1), - }, - submit: { - margin: theme.spacing(3, 0, 2), - }, -})); - -const Signup = () => { - const classes = useStyles(); - const [formData, setFormData] = useState({ - email: '', - username: '', - password: '', - }); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleSubmit = async (e) => { - e.preventDefault(); - setIsSubmitting(true); - setError(null); - - try { - axiosapi.createUser(formData); - } catch (error) { - console.error('Error creating user:', error); - setError('Registration failed. Please try again.'); // Set an error message - } finally { - setIsSubmitting(false); - } - }; - - const handleChange = (e) => { - const { name, value } = e.target; - setFormData({ ...formData, [name]: value }); - }; - - return ( - - -
- - Sign Up - -
- - - - - - {error && {error}} -
-
- ); -}; - -export default Signup; diff --git a/frontend/src/components/signup/Signup.jsx b/frontend/src/components/signup/Signup.jsx new file mode 100644 index 0000000..04c34b7 --- /dev/null +++ b/frontend/src/components/signup/Signup.jsx @@ -0,0 +1,36 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faGoogle, faGithub } from "@fortawesome/free-brands-svg-icons"; + +export function Signup() { + return ( +
+
+

Create your account

+

Start spending more time on your own table.

+
+
+ +
+ +
+ +
+ +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/testAuth.jsx b/frontend/src/components/testAuth.jsx deleted file mode 100644 index abd7ba1..0000000 --- a/frontend/src/components/testAuth.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import axiosapi from '../api/AuthenticationApi'; -import { Button } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; - -function TestAuth() { - let Navigate = useNavigate(); - - const [message, setMessage] = useState(""); - - useEffect(() => { - // Fetch the "hello" data from the server when the component mounts - axiosapi.getGreeting().then(res => { - console.log(res.data); - setMessage(res.data.user); - }).catch(err => { - console.log(err); - setMessage(""); - }); - }, []); - - const logout = () => { - // Log out the user, clear tokens, and navigate to the "/testAuth" route - axiosapi.apiUserLogout(); - Navigate('/testAuth'); - } - - return ( -
- {message !== "" && ( -
-

Login! Hello!

-

{message}

- -
- )} - {message === "" &&

Need to sign in, No authentication found

} -
- ); -} - -export default TestAuth; diff --git a/frontend/src/contexts/AuthContextProvider.jsx b/frontend/src/contexts/AuthContextProvider.jsx new file mode 100644 index 0000000..fea0243 --- /dev/null +++ b/frontend/src/contexts/AuthContextProvider.jsx @@ -0,0 +1,29 @@ +import { useEffect } from "react"; +import PropTypes from "prop-types"; +import { createContext, useState } from "react"; + +const AuthContext = createContext(); + +export const AuthProvider = ({ children }) => { + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const accessToken = localStorage.getItem("access_token"); + if (accessToken) { + setIsAuthenticated(true); + } + }, []); + + const contextValue = { + isAuthenticated, + setIsAuthenticated, + }; + + return {children}; +}; + +AuthProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export default AuthContext; diff --git a/frontend/src/hooks/AuthHooks.jsx b/frontend/src/hooks/AuthHooks.jsx new file mode 100644 index 0000000..27d3afb --- /dev/null +++ b/frontend/src/hooks/AuthHooks.jsx @@ -0,0 +1,36 @@ +import { useContext } from "react"; +import AuthContext from "src/contexts/AuthContextProvider"; + +/** + * useAuth - Custom React Hook for Accessing Authentication Context + * + * @returns {Object} An object containing: + * - {boolean} isAuthenticated: A boolean indicating whether the user is authenticated. + * - {function} setIsAuthenticated: A function to set the authentication status manually. + * + * @throws {Error} If used outside the context of an AuthProvider. + * + * @example + * // Import the hook + * import useAuth from './AuthHooks'; + * + * // Inside a functional component + * const { isAuthenticated, setIsAuthenticated } = useAuth(); + * + * // Check authentication status + * if (isAuthenticated) { + * // User is authenticated + * } else { + * // User is not authenticated + * } + * + * // Manually set authentication status + * setIsAuthenticated(true); + */ +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index daa8633..fcd32b6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -8,20 +8,18 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", + "Droid Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } -.nav-link{ - color:black; +.nav-link { + color: black; /* border: 1px solid white; */ padding: 1em; -} \ No newline at end of file +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index f5430b9..9d45787 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,15 +1,20 @@ -import React from "react"; +import { Fragment } from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; -import { GoogleOAuthProvider} from '@react-oauth/google'; -import { BrowserRouter } from 'react-router-dom'; +import { GoogleOAuthProvider } from "@react-oauth/google"; +import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "./contexts/AuthContextProvider"; -const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID +const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID; ReactDOM.createRoot(document.getElementById("root")).render( - + + + + + -); \ No newline at end of file +); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index c85efdb..f715156 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,21 +1,95 @@ /** @type {import('tailwindcss').Config} */ -const defaultTheme = require('tailwindcss/defaultTheme') +const defaultTheme = require("tailwindcss/defaultTheme"); export default { - content: ["./src/**/*.{js,jsx}"], + content: ["./src/**/*.{js,jsx}", "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}"], + theme: { extend: { fontFamily: { - 'sans': ['"Proxima Nova"', ...defaultTheme.fontFamily.sans], + sans: ['"Proxima Nova"', ...defaultTheme.fontFamily.sans], + }, + colors: { + tremor: { + brand: { + faint: "#eff6ff", // blue-50 + muted: "#bfdbfe", // blue-200 + subtle: "#60a5fa", // blue-400 + DEFAULT: "#3b82f6", // blue-500 + emphasis: "#1d4ed8", // blue-700 + inverted: "#ffffff", // white + }, + background: { + muted: "#f9fafb", // gray-50 + subtle: "#f3f4f6", // gray-100 + DEFAULT: "#ffffff", // white + emphasis: "#374151", // gray-700 + }, + border: { + DEFAULT: "#e5e7eb", // gray-200 + }, + ring: { + DEFAULT: "#e5e7eb", // gray-200 + }, + content: { + subtle: "#9ca3af", // gray-400 + DEFAULT: "#6b7280", // gray-500 + emphasis: "#374151", // gray-700 + strong: "#111827", // gray-900 + inverted: "#ffffff", // white + }, + }, + }, + boxShadow: { + "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", + "tremor-card": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", + "tremor-dropdown": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + }, + borderRadius: { + "tremor-small": "0.375rem", + "tremor-default": "0.5rem", + "tremor-full": "9999px", + }, + fontSize: { + "tremor-label": ["0.75rem"], + "tremor-default": ["0.875rem", { lineHeight: "1.25rem" }], + "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }], + "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }], }, }, }, - plugins: [require("daisyui"), - require("@tailwindcss/typography"), - require("daisyui") - ], + safelist: [ + { + pattern: + /^(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ["hover", "ui-selected"], + }, + { + pattern: + /^(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ["hover", "ui-selected"], + }, + { + pattern: + /^(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + variants: ["hover", "ui-selected"], + }, + { + pattern: + /^(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + { + pattern: + /^(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + { + pattern: + /^(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))$/, + }, + ], + plugins: [require("daisyui"), require("@tailwindcss/typography"), require("@headlessui/tailwindcss")], daisyui: { themes: ["light", "night"], }, -} +}; diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..59fd940 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + {"source": "/(.*)", "destination": "/"} + ] +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5a33944..536af4a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,7 +1,14 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; -// https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + resolve: { + alias: { + src: "/src", + }, + }, + define: { + __APP_ENV__: process.env.VITE_VERCEL_ENV, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2b9f188 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false