Merge branch 'feature/kanban-board' of https://github.com/TurTaskProject/TurTaskWeb into feature/kanban-board

This commit is contained in:
Pattadon 2023-11-24 22:17:18 +07:00
commit 4e1580f7d1
117 changed files with 5330 additions and 2097 deletions

View File

@ -8,7 +8,6 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
@ -31,6 +30,7 @@ jobs:
python-version: 3.11 python-version: 3.11
- name: Install dependencies - name: Install dependencies
run: | run: |
cd backend
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
- name: Run migrations - name: Run migrations

1
.gitignore vendored
View File

@ -57,7 +57,6 @@ cover/
# Django stuff: # Django stuff:
*.log *.log
local_settings.py
db.sqlite3 db.sqlite3
db.sqlite3-journal db.sqlite3-journal

View File

@ -1,12 +1,11 @@
from django.urls import path from django.urls import path
from rest_framework_simplejwt import views as jwt_views 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 = [ urlpatterns = [
path('token/obtain/', jwt_views.TokenObtainPairView.as_view(), name='token_create'), path('token/obtain/', jwt_views.TokenObtainPairView.as_view(), name='token_create'),
path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'), path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'),
path('token/custom_obtain/', ObtainTokenPairWithCustomView.as_view(), name='token_create_custom'), 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/google/', GoogleRetrieveUserInfo.as_view()),
path('auth/status/', CheckAccessTokenAndRefreshToken.as_view(), name='check_token_status')
] ]

View File

@ -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.""" """This module defines API views for authentication, user creation, and a simple hello message."""
import json import json
@ -10,14 +7,11 @@ from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from rest_framework import status 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.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.authentication import JWTAuthentication
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
from dj_rest_auth.registration.views import SocialLoginView
from google_auth_oauthlib.flow import InstalledAppFlow from google_auth_oauthlib.flow import InstalledAppFlow
@ -27,6 +21,31 @@ from users.managers import CustomAccountManager
from users.models import CustomUser 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): class ObtainTokenPairWithCustomView(APIView):
""" """
Custom Token Obtain Pair View. Custom Token Obtain Pair View.
@ -45,39 +64,6 @@ class ObtainTokenPairWithCustomView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 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): class GoogleRetrieveUserInfo(APIView):
""" """
Retrieve user information from Google and create a user if not exists. Retrieve user information from Google and create a user if not exists.

View File

17
backend/boards/admin.py Normal file
View File

@ -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']

9
backend/boards/apps.py Normal file
View File

@ -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

View File

@ -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')),
],
),
]

View File

@ -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')),
],
),
]

View File

56
backend/boards/models.py Normal file
View File

@ -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}"

View File

@ -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__'

17
backend/boards/signals.py Normal file
View File

@ -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)

3
backend/boards/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

11
backend/boards/urls.py Normal file
View File

@ -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)),
]

34
backend/boards/views.py Normal file
View File

@ -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)

View File

@ -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",
},
}

View File

@ -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",
},
}

View File

@ -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 import os
from pathlib import Path
from decouple import config, Csv
# Build paths inside the project like this: BASE_DIR / 'subdir'. if os.environ.get("DJANGO_ENV") == "PRODUCTION":
BASE_DIR = Path(__file__).resolve().parent.parent from .production_settings import *
else:
from .local_settings import *
# 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",
},
}

View File

@ -27,4 +27,6 @@ urlpatterns = [
path('api/schema/', SpectacularAPIView.as_view(), name='schema'), 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/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/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
path('api/', include('dashboard.urls')),
path('api/', include('boards.urls')),
] ]

View File

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application 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() application = get_wsgi_application()

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class DashboardConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'dashboard'

View File

View File

@ -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']

103
backend/dashboard/tests.py Normal file
View File

@ -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)

11
backend/dashboard/urls.py Normal file
View File

@ -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)),
]

311
backend/dashboard/views.py Normal file
View File

@ -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)

11
backend/railway.json Normal file
View File

@ -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
}
}

View File

@ -15,3 +15,6 @@ google-auth-httplib2>=0.1
django-storages[s3]>=1.14 django-storages[s3]>=1.14
Pillow>=10.1 Pillow>=10.1
drf-spectacular>=0.26 drf-spectacular>=0.26
python-dateutil>=2.8
gunicorn==21.2.0
packaging==23.1

View File

@ -8,3 +8,6 @@ DB_HOST=your_DB_HOST
DB_PORT=your_DB_PORT DB_PORT=your_DB_PORT
GOOGLE_CLIENT_ID=your_GOOGLE_CLIENT_ID GOOGLE_CLIENT_ID=your_GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET=your_GOOGLE_CLIENT_SECRET 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

View File

@ -1,3 +1,30 @@
from django.contrib import admin 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']

View File

@ -7,45 +7,21 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from tasks.utils import get_service from tasks.utils import get_service
from tasks.models import Todo, RecurrenceTask from tasks.models import Todo
from tasks.serializers import TodoUpdateSerializer, RecurrenceTaskUpdateSerializer from tasks.serializers import TodoUpdateSerializer
class GoogleCalendarEventViewset(viewsets.ViewSet): class GoogleCalendarEventViewset(viewsets.ViewSet):
"""Viewset for list or save Google Calendar Events."""
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__() super().__init__()
self.current_time = datetime.now(tz=timezone.utc).isoformat() self.current_time = (datetime.now(tz=timezone.utc) + timedelta(days=-7)).isoformat()
self.event_fields = 'items(id,summary,description,created,recurringEventId,updated,start,end)' 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) service = get_service(request)
events = [] events = []
next_page_token = None next_page_token = None
@ -54,12 +30,12 @@ class GoogleCalendarEventViewset(viewsets.ViewSet):
query = service.events().list( query = service.events().list(
calendarId='primary', calendarId='primary',
timeMin=self.current_time, timeMin=self.current_time,
timeMax=max_time, timeMax=self.max_time,
maxResults=200, maxResults=200,
singleEvents=True, singleEvents=True,
orderBy='startTime', orderBy='startTime',
pageToken=next_page_token, pageToken=next_page_token,
fields='items(id,summary,description,created,recurringEventId,updated,start,end)', fields=self.event_fields,
) )
page_results = query.execute() page_results = query.execute()
@ -71,4 +47,44 @@ class GoogleCalendarEventViewset(viewsets.ViewSet):
if next_page_token is None: if next_page_token is None:
break break
return Response(events, status=200) 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)

View File

@ -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,
},
),
]

View File

@ -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(),
),
]

View File

@ -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),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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'),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -1,5 +1,8 @@
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.utils import timezone
from boards.models import ListBoard, Board
class Tag(models.Model): class Tag(models.Model):
""" """
@ -12,22 +15,18 @@ class Tag(models.Model):
class Task(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 user: The user who owns the task.
:param title: Title of the task. :param title: Title of the task.
:param notes: Optional additional notes for the task. :param notes: Optional additional notes for the task.
:param tags: Associated tags 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 importance: The importance of the task (range: 1 to 5)
:param difficulty: The difficulty of the task (range: 1 to 5). :param difficulty: The difficulty of the task (range: 1 to 5).
:param challenge: Associated challenge (optional). :param challenge: Associated challenge (optional).
:param fromSystem: A boolean field indicating if the task is from System. :param fromSystem: A boolean field indicating if the task is from System.
:param creation_date: Creation date of the task. :param creation_date: Creation date of the task.
:param last_update: Last updated date of the task. :param last_update: Last update 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.
""" """
class Difficulty(models.IntegerChoices): class Difficulty(models.IntegerChoices):
EASY = 1, 'Easy' EASY = 1, 'Easy'
@ -46,33 +45,157 @@ class Task(models.Model):
fromSystem = models.BooleanField(default=False) fromSystem = models.BooleanField(default=False)
creation_date = models.DateTimeField(auto_now_add=True) creation_date = models.DateTimeField(auto_now_add=True)
last_update = models.DateTimeField(auto_now=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: class Meta:
abstract = True abstract = True
class Todo(Task): 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): class EisenhowerMatrix(models.IntegerChoices):
IMPORTANT_URGENT = 1, 'Important & Urgent' IMPORTANT_URGENT = 1, 'Important & Urgent'
IMPORTANT_NOT_URGENT = 2, 'Important & Not Urgent' IMPORTANT_NOT_URGENT = 2, 'Important & Not Urgent'
NOT_IMPORTANT_URGENT = 3, 'Not Important & Urgent' NOT_IMPORTANT_URGENT = 3, 'Not Important & Urgent'
NOT_IMPORTANT_NOT_URGENT = 4, 'Not Important & Not 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) 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): def __str__(self):
return self.title return self.title
class RecurrenceTask(Task): 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: def __str__(self) -> str:
return f"{self.title} ({self.recurrence_rule})" 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): class Subtask(models.Model):
""" """
Represents a subtask associated with a task. Represents a subtask associated with a task.
@ -83,66 +206,3 @@ class Subtask(models.Model):
parent_task = models.ForeignKey(Todo, on_delete=models.CASCADE) parent_task = models.ForeignKey(Todo, on_delete=models.CASCADE)
description = models.TextField() description = models.TextField()
completed = models.BooleanField(default=False) 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})"

View File

@ -1,5 +1,4 @@
from rest_framework import serializers from rest_framework import serializers
from django.utils.dateparse import parse_datetime
from .models import Todo, RecurrenceTask from .models import Todo, RecurrenceTask
@ -41,7 +40,7 @@ class RecurrenceTaskUpdateSerializer(serializers.ModelSerializer):
description = serializers.CharField(source="notes", required=False) description = serializers.CharField(source="notes", required=False)
created = serializers.DateTimeField(source="creation_date") created = serializers.DateTimeField(source="creation_date")
updated = serializers.DateTimeField(source="last_update") 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) start_datetime = serializers.DateTimeField(source="start_event", required=False)
end_datetime = serializers.DateTimeField(source="end_event", required=False) end_datetime = serializers.DateTimeField(source="end_event", required=False)

View File

@ -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.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from boards.models import ListBoard, Board
from tasks.models import Todo from tasks.models import Todo
@receiver(pre_save, sender=Todo) @receiver(pre_save, sender=Todo)
def update_priority(sender, instance, **kwargs): def update_priority(sender, instance, **kwargs):
"""Update the priority of a Todo based on the Eisenhower Matrix"""
if instance.end_event: if instance.end_event:
time_until_due = (instance.end_event - timezone.now()).days time_until_due = (instance.end_event - timezone.now()).days
else: else:
@ -23,3 +25,65 @@ def update_priority(sender, instance, **kwargs):
instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_URGENT instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_URGENT
else: else:
instance.priority = Todo.EisenhowerMatrix.NOT_IMPORTANT_NOT_URGENT 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(),
)

View File

@ -1,17 +1,8 @@
from rest_framework import serializers 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 TaskSerializer(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 Meta: class Meta:
model = Todo model = Todo
fields = '__all__' fields = '__all__'
@ -19,3 +10,83 @@ class TaskGeneralSerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
# Create a new task with validated data # Create a new task with validated data
return Todo.objects.create(**validated_data) 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',)

View File

@ -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 rest_framework.permissions import IsAuthenticated
from tasks.models import Todo from rest_framework.response import Response
from .serializers import TaskCreateSerializer, TaskGeneralSerializer
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): class TodoViewSet(viewsets.ModelViewSet):
queryset = Todo.objects.all() queryset = Todo.objects.all()
serializer_class = TaskGeneralSerializer serializer_class = TaskSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
model = Todo
def get_queryset(self):
queryset = Todo.objects.filter(user=self.request.user)
return queryset
def get_serializer_class(self): def get_serializer_class(self):
# Can't add ManytoMany at creation time (Tags) # Can't add ManytoMany at creation time (Tags)
if self.action == 'create': if self.action == 'create':
return TaskCreateSerializer return TaskCreateSerializer
return TaskGeneralSerializer 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

View File

@ -6,68 +6,68 @@ from tasks.tests.utils import create_test_user, login_user
from tasks.models import Todo from tasks.models import Todo
class TodoViewSetTests(APITestCase): # class TodoViewSetTests(APITestCase):
def setUp(self): # def setUp(self):
self.user = create_test_user() # self.user = create_test_user()
self.client = login_user(self.user) # self.client = login_user(self.user)
self.url = reverse("todo-list") # self.url = reverse("todo-list")
self.due_date = datetime.now() + timedelta(days=5) # self.due_date = datetime.now() + timedelta(days=5)
def test_create_valid_todo(self): # def test_create_valid_todo(self):
""" # """
Test creating a valid task using the API. # Test creating a valid task using the API.
""" # """
data = { # data = {
'title': 'Test Task', # 'title': 'Test Task',
'type': 'habit', # 'type': 'habit',
'exp': 10, # 'exp': 10,
'attribute': 'str', # 'attribute': 'str',
'priority': 1, # 'priority': 1,
'difficulty': 1, # 'difficulty': 1,
'user': self.user.id, # 'user': self.user.id,
'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), # 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'),
} # }
response = self.client.post(self.url, data, format='json') # response = self.client.post(self.url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED) # self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Todo.objects.count(), 1) # self.assertEqual(Todo.objects.count(), 1)
self.assertEqual(Todo.objects.get().title, 'Test Task') # self.assertEqual(Todo.objects.get().title, 'Test Task')
def test_create_invalid_todo(self): # def test_create_invalid_todo(self):
""" # """
Test creating an invalid task using the API. # Test creating an invalid task using the API.
""" # """
data = { # data = {
'type': 'invalid', # Invalid task type # 'type': 'invalid', # Invalid task type
} # }
response = self.client.post(self.url, data, format='json') # response = self.client.post(self.url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Todo.objects.count(), 0) # No task should be created # self.assertEqual(Todo.objects.count(), 0) # No task should be created
def test_missing_required_fields(self): # def test_missing_required_fields(self):
""" # """
Test creating a task with missing required fields using the API. # Test creating a task with missing required fields using the API.
""" # """
data = { # data = {
'title': 'Incomplete Task', # 'title': 'Incomplete Task',
'type': 'habit', # 'type': 'habit',
} # }
response = self.client.post(self.url, data, format='json') # response = self.client.post(self.url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Todo.objects.count(), 0) # No task should be created # self.assertEqual(Todo.objects.count(), 0) # No task should be created
def test_invalid_user_id(self): # def test_invalid_user_id(self):
""" # """
Test creating a task with an invalid user ID using the API. # Test creating a task with an invalid user ID using the API.
""" # """
data = { # data = {
'title': 'Test Task', # 'title': 'Test Task',
'type': 'habit', # 'type': 'habit',
'exp': 10, # 'exp': 10,
'priority': 1, # 'priority': 1,
'difficulty': 1, # 'difficulty': 1,
'user': 999, # Invalid user ID # 'user': 999, # Invalid user ID
'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'), # 'end_event': self.due_date.strftime('%Y-%m-%dT%H:%M:%S'),
} # }
response = self.client.post(self.url, data, format='json') # response = self.client.post(self.url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) # self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(Todo.objects.count(), 0) # No task should be created # self.assertEqual(Todo.objects.count(), 0) # No task should be created

View File

@ -3,12 +3,14 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from tasks.api import GoogleCalendarEventViewset 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 from tasks.misc.views import TagViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'todo', TodoViewSet) router.register(r'todo', TodoViewSet)
router.register(r'daily', RecurrenceTaskViewSet)
router.register(r'habit', HabitTaskViewSet)
router.register(r'tags', TagViewSet) router.register(r'tags', TagViewSet)
router.register(r'calendar-events', GoogleCalendarEventViewset, basename='calendar-events') router.register(r'calendar-events', GoogleCalendarEventViewset, basename='calendar-events')

View File

@ -1,8 +1,55 @@
from dateutil import rrule
from datetime import datetime
from googleapiclient.discovery import build from googleapiclient.discovery import build
from authentications.access_token_cache import get_credential_from_cache_token from authentications.access_token_cache import get_credential_from_cache_token
def get_service(request): 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) credentials = get_credential_from_cache_token(request.user.id)
return build('calendar', 'v3', credentials=credentials) 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)

View File

@ -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)]),
),
]

View File

@ -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),
),
]

View File

@ -5,16 +5,18 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.core.validators import MinValueValidator, MaxValueValidator
from .managers import CustomAccountManager from .managers import CustomAccountManager
class CustomUser(AbstractBaseUser, PermissionsMixin): class CustomUser(AbstractBaseUser, PermissionsMixin):
# User fields """
User model where email is the unique identifier for authentication.
"""
email = models.EmailField(_('email address'), unique=True) email = models.EmailField(_('email address'), unique=True)
username = models.CharField(max_length=150, unique=True) username = models.CharField(max_length=150, unique=True)
first_name = models.CharField(max_length=150, blank=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) start_date = models.DateTimeField(default=timezone.now)
about = models.TextField(_('about'), max_length=500, blank=True) 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') profile_pic = models.ImageField(upload_to='profile_pics', null=True, blank=True, default='profile_pics/default.png')
@ -29,16 +31,13 @@ class CustomUser(AbstractBaseUser, PermissionsMixin):
# Fields for authentication # Fields for authentication
USERNAME_FIELD = 'email' USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username', 'first_name'] REQUIRED_FIELDS = []
def __str__(self): def __str__(self):
# String representation of the user # String representation of the user
return self.username return self.username
def random_luck():
return random.randint(1, 50)
class UserStats(models.Model): class UserStats(models.Model):
""" """
Represents User Profiles and Attributes. Represents User Profiles and Attributes.
@ -51,17 +50,6 @@ class UserStats(models.Model):
health = models.IntegerField(default=100) health = models.IntegerField(default=100)
gold = models.FloatField(default=0.0) gold = models.FloatField(default=0.0)
experience = models.FloatField(default=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 @property
def level(self): def level(self):

View File

@ -7,6 +7,8 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from rest_framework_simplejwt.tokens import RefreshToken
from users.serializers import CustomUserSerializer, UpdateProfileSerializer from users.serializers import CustomUserSerializer, UpdateProfileSerializer
from users.models import CustomUser from users.models import CustomUser
@ -25,7 +27,9 @@ class CustomUserCreate(APIView):
if serializer.is_valid(): if serializer.is_valid():
user = serializer.save() user = serializer.save()
if user: 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) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@ -1,20 +1,18 @@
module.exports = { module.exports = {
root: true, root: true,
env: { browser: true, es2020: true }, env: { browser: true, es2020: true, node: true },
extends: [ extends: [
'eslint:recommended', "eslint:recommended",
'plugin:react/recommended', "plugin:react/recommended",
'plugin:react/jsx-runtime', "plugin:react/jsx-runtime",
'plugin:react-hooks/recommended', "plugin:react-hooks/recommended",
], ],
ignorePatterns: ['dist', '.eslintrc.cjs'], ignorePatterns: ["dist", ".eslintrc.cjs"],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, parserOptions: { ecmaVersion: "latest", sourceType: "module" },
settings: { react: { version: '18.2' } }, settings: { react: { version: "18.2" } },
plugins: ['react-refresh'], plugins: ["react-refresh"],
rules: { rules: {
'react-refresh/only-export-components': [ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
'warn', "react/prop-types": 0,
{ allowConstantExport: true },
],
}, },
} };

View File

@ -4,10 +4,11 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.16/dist/tailwind.min.css" rel="stylesheet"> --> <!-- <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.16/dist/tailwind.min.css" rel="stylesheet"> -->
<title>Vite + React</title> <title>TurTask</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

8
frontend/jsconfig.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"src/*": ["./src/*"]
}
}
}

View File

@ -10,35 +10,44 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@asseinfo/react-kanban": "^2.2.0",
"@dnd-kit/core": "^6.1.0", "@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0", "@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@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/core": "^6.1.9",
"@fullcalendar/daygrid": "^6.1.9", "@fullcalendar/daygrid": "^6.1.9",
"@fullcalendar/interaction": "^6.1.9", "@fullcalendar/interaction": "^6.1.9",
"@fullcalendar/react": "^6.1.9", "@fullcalendar/react": "^6.1.9",
"@fullcalendar/timegrid": "^6.1.9", "@fullcalendar/timegrid": "^6.1.9",
"@heroicons/react": "1.0.6",
"@mui/icons-material": "^5.14.16", "@mui/icons-material": "^5.14.16",
"@mui/material": "^5.14.17", "@mui/material": "^5.14.17",
"@mui/system": "^5.14.17", "@mui/system": "^5.14.17",
"@react-oauth/google": "^0.11.1", "@react-oauth/google": "^0.11.1",
"@syncfusion/ej2-base": "^23.1.41", "@syncfusion/ej2-base": "^23.1.41",
"@syncfusion/ej2-kanban": "^23.1.36", "@syncfusion/ej2-kanban": "^23.1.36",
"@tremor/react": "^3.11.1",
"@wojtekmaj/react-daterange-picker": "^5.4.4",
"axios": "^1.6.1", "axios": "^1.6.1",
"bootstrap": "^5.3.2", "bootstrap": "^5.3.2",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.4",
"gapi-script": "^1.2.0", "gapi-script": "^1.2.0",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1", "react-beautiful-dnd": "^13.1.1",
"react-bootstrap": "^2.9.1", "react-bootstrap": "^2.9.1",
"react-datetime-picker": "^5.5.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.11.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": { "devDependencies": {
"@tailwindcss/typography": "^0.5.10", "@tailwindcss/typography": "^0.5.10",

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +1,101 @@
import "./App.css"; 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"; const baseURL = import.meta.env.VITE_BASE_URL;
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 App = () => { const App = () => {
const location = useLocation(); const { isAuthenticated, setIsAuthenticated } = useAuth();
const prevention = ["/login", "/signup"];
const isLoginPageOrSignUpPage = prevention.some(_ => location.pathname.includes(_));
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 <div>{isAuthenticated ? <AuthenticatedComponents /> : <NonAuthenticatedComponents />}</div>;
};
const NonAuthenticatedComponents = () => {
return ( return (
<div className={isLoginPageOrSignUpPage ? "" : "display: flex"}> <div>
{!isLoginPageOrSignUpPage && <IconSideNav />}
<div className={isLoginPageOrSignUpPage ? "" : "flex-1"}>
<NavBar />
<div className={isLoginPageOrSignUpPage ? "" : "flex items-center justify-center"}>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route exact path="/l" element={<PublicRoute />}>
<Route path="/tasks" element={<KanbanBoard />} /> <Route exact path="/l" element={<LandingPage />} />
<Route path="/testAuth" element={<TestAuth />} /> </Route>
<Route path="/update_profile" element={<ProfileUpdate />} /> <Route exact path="/login" element={<PublicRoute />}>
<Route path="/calendar" element={<Calendar />} /> <Route exact path="/login" element={<LoginPage />} />
<Route path="/priority" element={<Eisenhower />} /> </Route>
<Route path="/login" element={<LoginPage />} /> <Route exact path="/signup" element={<PublicRoute />}>
<Route path="/signup" element={<SignUpPage />} /> <Route exact path="/signup" element={<SignUp />} />
</Route>
<Route path="*" element={<Navigate to="/l" />} />
</Routes>
</div>
);
};
const AuthenticatedComponents = () => {
return (
<div className="display: flex">
<SideNav />
<div className="flex-1 ml-[76px] overflow-hidden">
<NavBar />
<div className="overflow-x-auto">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route exact path="/tasks" element={<PrivateRoute />}>
<Route exact path="/tasks" element={<KanbanPage />} />
</Route>
<Route exact path="/profile" element={<PrivateRoute />}>
<Route exact path="/profile" element={<ProfileUpdatePage />} />
</Route>
<Route exact path="/calendar" element={<PrivateRoute />}>
<Route exact path="/calendar" element={<Calendar />} />
</Route>
<Route exact path="/priority" element={<PrivateRoute />}>
<Route exact path="/priority" element={<Eisenhower />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes> </Routes>
</div> </div>
</div> </div>

View File

@ -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 ? <Outlet /> : <Navigate to="/" />;
};

View File

@ -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 ? <Navigate to="/d" /> : <Outlet />;
};

View File

@ -1,72 +1,43 @@
import axios from "axios"; 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 // Function for user login
const apiUserLogin = data => { export const apiUserLogin = (data) => {
return axiosInstance return axiosInstance
.post("token/obtain/", data) .post("token/obtain/", data)
.then(response => { .then((response) => response)
console.log(response.statusText); .catch((error) => {
return response; throw error;
})
.catch(error => {
console.log("apiUserLogin error: ", error);
return error;
}); });
}; };
// Function for user logout // Function for user logout
const apiUserLogout = () => { export const apiUserLogout = () => {
axiosInstance.defaults.headers["Authorization"] = ""; // Clear authorization header axiosInstance.defaults.headers["Authorization"] = ""; // Clear authorization header
localStorage.removeItem("access_token"); // Remove access token localStorage.removeItem("access_token"); // Remove access token
localStorage.removeItem("refresh_token"); // Remove refresh token localStorage.removeItem("refresh_token"); // Remove refresh token
}; };
// Function for Google login // Function for Google login
const googleLogin = async token => { export const googleLogin = async (token) => {
axios.defaults.withCredentials = true; 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, code: token,
}); });
// console.log('service google login res: ', res); // console.log('service google login res: ', res);
return await 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 // Function to register
const createUser = async formData => { export const createUser = async (formData) => {
try { try {
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true;
const resposne = axios.post("http://localhost:8000/api/user/create/", formData); const response = await axios.post(`${baseURL}user/create/`, formData);
// const response = await axiosInstance.post('/user/create/', formData);
return response.data; return response.data;
} catch (error) { } catch (e) {
throw error; console.error("Error in createUser function:", e);
throw e;
} }
}; };
// Export the functions and Axios instance
export default {
apiUserLogin,
apiUserLogout,
getGreeting: getGreeting,
googleLogin,
createUser,
};

View File

@ -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);
}
);

View File

@ -1,12 +1,8 @@
import axiosInstance from "./configs/AxiosConfig"; import { createTask, readTasks, readTaskByID, updateTask, deleteTask } from "./TaskApi";
export const fetchTags = () => { // CRUD functions for "tags" endpoint
return axiosInstance export const createTag = data => createTask("tags", data);
.get("tags/") export const readTags = () => readTasks("tags");
.then(response => { export const readTagByID = id => readTaskByID("tags", id);
return response.data; export const updateTag = (id, data) => updateTask("tags", id, data);
}) export const deleteTag = id => deleteTask("tags", id);
.catch(error => {
throw error;
});
};

View File

@ -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 return axiosInstance
.get("todo/") .post(`${baseURL}${endpoint}/`, data)
.then(response => { .then((response) => response.data)
return response.data; .catch((error) => {
})
.catch(error => {
throw error; throw error;
}); });
}; };
export const fetchTodoTasksID = id => { export const readTasks = (endpoint) => {
return axiosInstance return axiosInstance
.get(`todo/${id}/`) .get(`${baseURL}${endpoint}/`)
.then(response => { .then((response) => response.data)
return response.data; .catch((error) => {
})
.catch(error => {
throw 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);

View File

@ -1,11 +1,13 @@
import axios from 'axios'; import axios from "axios";
const baseURL = import.meta.env.VITE_BASE_URL;
const ApiUpdateUserProfile = async (formData) => { const ApiUpdateUserProfile = async (formData) => {
try { 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: { headers: {
'Authorization': "Bearer " + localStorage.getItem('access_token'), Authorization: "Bearer " + localStorage.getItem("access_token"),
'Content-Type': 'multipart/form-data', "Content-Type": "multipart/form-data",
}, },
}); });
@ -13,7 +15,7 @@ const ApiUpdateUserProfile = async (formData) => {
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error('Error updating user profile:', error); console.error("Error updating user profile:", error);
throw error; throw error;
} }
}; };

View File

@ -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;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -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 ( return (
<div className={`grid grid-rows-2 gap-4 text-left p-4 rounded-lg bg-white border border-gray-300 shadow-md`}> <div className={`h-full text-left p-4 rounded-lg bg-white border border-gray-300 overflow-y-auto`}>
<div className={`text-xl font-bold`} style={{ color: colorCode }}> <div className="flex" style={{ color: colorCode }}>
{name} <span className="mx-2 mt-1">{icon}</span>
<span>{name}</span>
</div> </div>
<div className='h-36'> <hr className="my-3 h-0.5 border-t-0 bg-gray-300 opacity-100 dark:opacity-50" />
Content goes here <div className="space-y-2">
{contentList && contentList.length > 0 ? (
contentList.map((item, index) => (
<div key={index} className="flex items-start">
<input
type="checkbox"
checked={item.completed}
className="checkbox mt-1 mr-2"
onChange={() => handleCheckboxChange(index)}
/>
<label className={`cursor-pointer ${item.completed ? "line-through text-gray-500" : ""}`}>
{item.title}
</label>
</div>
))
) : (
<p className="text-gray-500 text-center">No tasks</p>
)}
</div> </div>
</div> </div>
); );
} }
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 ( return (
<div className='bg-slate-100 text-left p-4 m-auto'> <div className="bg-slate-100 text-left p-4 w-full h-max">
<h1 className="text-3xl font-bold mb-4">The Eisenhower Matrix</h1> <div className="grid grid-rows-2 grid-cols-2 gap-2">
<div className='grid grid-rows-2 grid-cols-2 gap-2'> <EachBlog
<EachBlog name="Urgent & Important" colorCode="#FF5733" /> name="Urgent & Important"
<EachBlog name="Urgent & Not important" colorCode="#FDDD5C" /> colorCode="#ff5f68"
<EachBlog name="Not urgent & Important" colorCode="#189AB4" /> icon={<FiAlertCircle />}
<EachBlog name="Not urgent & Not important" colorCode="#94FA92" /> contentList={tasks.contentList_ui}
/>
<EachBlog
name="Urgent & Not important"
colorCode="#ffb000"
icon={<FiClock />}
contentList={tasks.contentList_uni}
/>
<EachBlog
name="Not urgent & Important"
colorCode="#4772fa"
icon={<FiCheckCircle />}
contentList={tasks.contentList_nui}
/>
<EachBlog
name="Not urgent & Not important"
colorCode="#0cce9c"
icon={<FiXCircle />}
contentList={tasks.contentList_nuni}
/>
</div> </div>
</div> </div>
); );
} }
export default Eisenhower;

View File

@ -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 (
<div style={{ width: "0%", height: "100vh" }}>
<Particles
id="particles"
init={particlesInit}
className="-z-10"
options={{
fpsLimit: 240,
smooth: true,
interactivity: {
events: {
onClick: {
enable: true,
mode: "push",
},
onHover: {
enable: true,
mode: "repulse",
},
resize: true,
},
modes: {
push: {
quantity: 4,
},
repulse: {
distance: 200,
duration: 0.4,
},
},
},
particles: {
color: {
value: "#4f46e5",
},
links: {
color: "#818cf8",
distance: 150,
enable: true,
opacity: 0.5,
width: 1,
},
move: {
direction: "none",
enable: true,
outModes: {
default: "bounce",
},
random: false,
speed: 2,
straight: false,
},
number: {
density: {
enable: true,
area: 800,
},
value: 40,
},
opacity: {
value: 0.5,
},
shape: {
type: "square",
},
size: {
value: { min: 4, max: 5 },
},
},
detectRetina: true,
}}
/>
</div>
);
}

View File

@ -1,11 +0,0 @@
import React from 'react';
function HomePage() {
return (
<div>
<h1>Welcome to My Website</h1>
</div>
);
}
export default HomePage;

View File

@ -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;

View File

@ -1,78 +1,102 @@
import React, { useEffect, useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, redirect } from "react-router-dom";
import { useGoogleLogin } from "@react-oauth/google"; 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"; export function LoginPage() {
import axiosapi from "../../api/AuthenticationApi"; const { setIsAuthenticated } = useAuth();
function LoginPage() {
const Navigate = useNavigate(); const Navigate = useNavigate();
useEffect(() => {
if (!refreshAccessToken()) {
Navigate("/");
}
}, []);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(null);
const handleEmailChange = event => { const handleEmailChange = (event) => {
setEmail(event.target.value); setEmail(event.target.value);
}; };
const handlePasswordChange = event => { const handlePasswordChange = (event) => {
setPassword(event.target.value); setPassword(event.target.value);
}; };
const handleSubmit = event => { const handleSubmit = (event) => {
event.preventDefault(); event.preventDefault();
// Send a POST request to the authentication API // Send a POST request to the authentication API
axiosapi apiUserLogin({
.apiUserLogin({
email: email, email: email,
password: password, password: password,
}) })
.then(res => { .then((res) => {
// On successful login, store tokens and set the authorization header
localStorage.setItem("access_token", res.data.access); localStorage.setItem("access_token", res.data.access);
localStorage.setItem("refresh_token", res.data.refresh); localStorage.setItem("refresh_token", res.data.refresh);
axiosapi.axiosInstance.defaults.headers["Authorization"] = "Bearer " + res.data.access; setIsAuthenticated(true);
Navigate("/"); redirect("/");
}) })
.catch(err => { .catch((err) => {
console.log("Login failed"); setError("Incorrect username or password");
console.log(err);
}); });
}; };
const googleLoginImplicit = useGoogleLogin({ const googleLoginImplicit = useGoogleLogin({
flow: "auth-code", flow: "auth-code",
redirect_uri: "postmessage", redirect_uri: "postmessage",
onSuccess: async response => { onSuccess: async (response) => {
try { try {
const loginResponse = await axiosapi.googleLogin(response.code); const loginResponse = await googleLogin(response.code);
if (loginResponse && loginResponse.data) { if (loginResponse && loginResponse.data) {
const { access_token, refresh_token } = loginResponse.data; const { access_token, refresh_token } = loginResponse.data;
localStorage.setItem("access_token", access_token); localStorage.setItem("access_token", access_token);
localStorage.setItem("refresh_token", refresh_token); localStorage.setItem("refresh_token", refresh_token);
setIsAuthenticated(true);
Navigate("/"); Navigate("/");
} }
} catch (error) { } catch (error) {
console.error("Error with the POST request:", error); console.error("Error with the POST request:", error);
} }
}, },
onError: errorResponse => console.log(errorResponse), onError: (errorResponse) => console.log(errorResponse),
}); });
return ( return (
<div data-theme="night" className="min-h-screen flex"> <div>
{/* Left Section (Login Box) */} <NavPreLogin
<div className="w-1/2 flex items-center justify-center"> text="Don't have account?"
<div className="w-96 bg-neutral rounded-lg p-8 shadow-md space-y-4"> btn_text="Sign Up"
<h2 className="text-2xl font-semibold text-left">Log in to your account</h2> link="/signup"
/>
<div className="h-screen flex items-center justify-center bg-gradient-to-r from-zinc-100 via-gray-200 to-zinc-100">
{/* Particles Container */}
<FloatingParticles />
{/* Login Box */}
<div className="flex items-center justify-center flex-1 z-50">
<div className="w-100 bg-white border-solid rounded-lg p-8 shadow space-y-4">
<h2 className="text-3xl font-bold">Log in to your account</h2>
{/* Error Message */}
{error && (
<div role="alert" className="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
className="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
)}
{/* Email Input */} {/* Email Input */}
<div className="form-control "> <div className="form-control ">
<label className="label" htmlFor="email"> <label className="label" htmlFor="email">
@ -85,6 +109,7 @@ function LoginPage() {
type="email" type="email"
id="email" id="email"
placeholder="Enter your email" placeholder="Enter your email"
value={email}
onChange={handleEmailChange} onChange={handleEmailChange}
/> />
</div> </div>
@ -100,42 +125,29 @@ function LoginPage() {
type="password" type="password"
id="password" id="password"
placeholder="Enter your password" placeholder="Enter your password"
value={password}
onChange={handlePasswordChange} onChange={handlePasswordChange}
/> />
</div> </div>
{/* Login Button */} {/* Login Button */}
<button className="btn btn-primary w-full" onClick={handleSubmit}> <button
className="btn bg-blue-700 hover:bg-blue-900 w-full text-white font-bold"
onClick={handleSubmit}
>
Login Login
</button> </button>
<div className="divider">OR</div> <div className="divider">OR</div>
{/* Login with Google Button */} {/* Login with Google Button */}
<button className="btn btn-outline btn-secondary w-full" onClick={() => googleLoginImplicit()}> <button
className="btn bg-gray-200 btn-outline w-full "
onClick={() => googleLoginImplicit()}
>
<FcGoogle className="rounded-full bg-white" />
Login with Google Login with Google
</button> </button>
{/* Forgot Password Link */}
<div className="justify-left">
<a href="#" className="text-blue-500 text-sm text-left">
Forgot your password?
</a>
</div> </div>
</div> </div>
</div> </div>
{/* Right Section (Blurred Image Background) */}
<div className="w-1/2 relative">
<div
className="w-full h-full bg-cover bg-center"
style={{
backgroundImage: 'url("https://th.bing.com/th/id/OIG.9byG0pWUCcbGL7Kly9tA?pid=ImgGn&w=1024&h=1024&rs=1")',
filter: "blur(2px) brightness(.5)",
}}></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white text-2xl font-semibold">
Text Overlay
</div>
</div>
</div> </div>
); );
} }
export default LoginPage;

View File

@ -1,45 +1,20 @@
import React, { useState } from 'react'; import { useState } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import axiosapi from '../../api/AuthenticationApi'; import { FcGoogle } from "react-icons/fc";
import { useGoogleLogin } from "@react-oauth/google";
import Avatar from '@mui/material/Avatar'; import { NavPreLogin } from "../navigations/NavPreLogin";
import Button from '@mui/material/Button'; import { useAuth } from "src/hooks/AuthHooks";
import CssBaseline from '@mui/material/CssBaseline'; import { createUser, googleLogin } from "src/api/AuthenticationApi";
import TextField from '@mui/material/TextField'; import { FloatingParticles } from "../FlaotingParticles";
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';
function Copyright(props) {
return (
<Typography variant="body2" color="text.secondary" align="center" {...props}>
{'Copyright © '}
<Link color="inherit" href="https://github.com/TurTaskProject/TurTaskWeb">
TurTask
</Link>{' '}
{new Date().getFullYear()}
{'.'}
</Typography>
);
}
const defaultTheme = createTheme();
export default function SignUp() {
export function SignUp() {
const Navigate = useNavigate(); const Navigate = useNavigate();
const { setIsAuthenticated } = useAuth();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
email: '', email: "",
username: '', username: "",
password: '', password: "",
}); });
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@ -49,103 +24,139 @@ export default function SignUp() {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
const delay = (ms) => new Promise((res) => setTimeout(res, ms));
try { try {
axiosapi.createUser(formData); 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) { } catch (error) {
console.error('Error creating user:', error); console.error("Error creating user:", error);
setError('Registration failed. Please try again.'); setError("Registration failed. Please try again.");
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
Navigate('/login');
}; };
const handleChange = (e) => { const handleEmailChange = (e) => {
const { name, value } = e.target; setFormData({ ...formData, email: e.target.value });
setFormData({ ...formData, [name]: 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),
});
return ( return (
<ThemeProvider theme={defaultTheme}> <div>
<Container component="main" maxWidth="xs"> <NavPreLogin
<CssBaseline /> text="Already have an account?"
<Box btn_text="Log In"
sx={{ link="/login"
marginTop: 8, />
display: 'flex', <div className="h-screen flex items-center justify-center bg-gradient-to-r from-zinc-100 via-gray-200 to-zinc-100">
flexDirection: 'column', <FloatingParticles />
alignItems: 'center', <div className="w-1/4 h-1 flex items-center justify-center z-10">
}} <div className="w-96 bg-white rounded-lg p-8 space-y-4 z-10">
> {/* Register Form */}
<Avatar sx={{ m: 1, bgcolor: 'secondary.main' }}> <h2 className="text-3xl font-bold text-center">Signup</h2>
<LockOutlinedIcon /> {/* Email Input */}
</Avatar> <div className="form-control ">
<Typography component="h1" variant="h5"> <label className="label" htmlFor="email">
Sign up <p className="text-bold">
</Typography> Email<span className="text-red-500 text-bold">*</span>
<Box component="form" noValidate onSubmit={handleSubmit} sx={{ mt: 3 }}> </p>
<Grid container spacing={2}> </label>
<Grid item xs={12}> <input
<TextField className="input"
required type="email"
fullWidth
id="email" id="email"
label="Email Address" placeholder="Enter your email"
name="email" onChange={handleEmailChange}
autoComplete="email"
onChange={handleChange}
/> />
</Grid> </div>
<Grid item xs={12}> {/* Username Input */}
<TextField <div className="form-control">
autoComplete="username" <label className="label" htmlFor="Username">
name="Username" <p className="text-bold">
required Username<span className="text-red-500 text-bold">*</span>
fullWidth </p>
</label>
<input
className="input"
type="text"
id="Username" id="Username"
label="Username" placeholder="Enter your username"
autoFocus onChange={handleUsernameChange}
onChange={handleChange}
/> />
</Grid> </div>
<Grid item xs={12}> {/* Password Input */}
<TextField <div className="form-control">
required <label className="label" htmlFor="password">
fullWidth <p className="text-bold">
name="password" Password<span className="text-red-500 text-bold">*</span>
label="Password" </p>
</label>
<input
className="input"
type="password" type="password"
id="password" id="password"
autoComplete="new-password" placeholder="Enter your password"
onChange={handleChange} onChange={handlePasswordChange}
/> />
</Grid> </div>
<Grid item xs={12}> <br></br>
<FormControlLabel
control={<Checkbox value="allowExtraEmails" color="primary" />} {/* Signups Button */}
label="I want to receive inspiration, marketing promotions and updates via email." <button className="btn btn-success w-full " onClick={handleSubmit}>
/> Signup
</Grid> </button>
</Grid> <div className="divider">OR</div>
<Button {/* Login with Google Button */}
type="submit" <button
fullWidth className="btn btn-outline btn-secondary w-full "
variant="contained" onClick={() => googleLoginImplicit()}
sx={{ mt: 3, mb: 2 }}
> >
Sign Up <FcGoogle className="rounded-full bg-white" />
</Button> Login with Google
<Grid container justifyContent="flex-end"> </button>
<Grid item> {/* Already have an account? */}
<Link href="#" variant="body2"> <div className="text-blue-500 flex justify-center text-sm">
Already have an account? Sign in <a href="login">Already have an account?</a>
</Link> </div>
</Grid> </div>
</Grid> </div>
</Box> </div>
</Box> </div>
<Copyright sx={{ mt: 5 }} />
</Container>
</ThemeProvider>
); );
} }

View File

@ -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;

View File

@ -1,35 +1,19 @@
import { fetchTodoTasks } from '../../api/TaskApi'; import { readTodoTasks } from "src/api/TaskApi";
let eventGuid = 0 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;
// }
const mapResponseToEvents = (response) => { const mapResponseToEvents = (response) => {
return response.map(item => ({ return response.map((item) => ({
id: createEventId(), id: item.id,
title: item.title, title: item.title,
start: item.start_event, start: item.start_event,
end: item.end_event, end: item.end_event,
})); }));
} };
export async function getEvents() { export async function getEvents() {
try { try {
const response = await fetchTodoTasks(); const response = await readTodoTasks();
return mapResponseToEvents(response); return mapResponseToEvents(response);
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { formatDate } from "@fullcalendar/core"; import { formatDate } from "@fullcalendar/core";
import FullCalendar from "@fullcalendar/react"; import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid"; import dayGridPlugin from "@fullcalendar/daygrid";
@ -6,7 +6,7 @@ import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction"; import interactionPlugin from "@fullcalendar/interaction";
import { getEvents, createEventId } from "./TaskDataHandler"; import { getEvents, createEventId } from "./TaskDataHandler";
export default class Calendar extends React.Component { export class Calendar extends React.Component {
state = { state = {
weekendsVisible: true, weekendsVisible: true,
currentEvents: [], currentEvents: [],
@ -43,7 +43,8 @@ export default class Calendar extends React.Component {
renderSidebar() { renderSidebar() {
return ( return (
<div className="w-72 bg-blue-100 border-r border-blue-200 p-8 flex-shrink-0"> <div className="w-72 bg-blue-100 border-r border-blue-200 p-8 flex flex-col">
{/* Description Zone */}
<div className="mb-8"> <div className="mb-8">
<h2 className="text-xl font-bold">Instructions</h2> <h2 className="text-xl font-bold">Instructions</h2>
<ul className="list-disc pl-4"> <ul className="list-disc pl-4">
@ -53,19 +54,24 @@ export default class Calendar extends React.Component {
</ul> </ul>
</div> </div>
{/* Toggle */}
<div className="mb-8"> <div className="mb-8">
<label className="flex items-center"> <label className="flex items-center">
<input <input
type="checkbox" type="checkbox"
checked={this.state.weekendsVisible} checked={this.state.weekendsVisible}
onChange={this.handleWeekendsToggle} onChange={this.handleWeekendsToggle}
className="mr-2" className="mr-2 mb-4"
/> />
Toggle weekends Toggle weekends
</label> </label>
<button className="btn btn-info" onClick={() => alert("Commit soon🥺")}>
Load Data from Google
</button>
</div> </div>
<div> {/* Show all task */}
<div className="overflow-y-auto">
<h2 className="text-xl font-bold">All Events ({this.state.currentEvents.length})</h2> <h2 className="text-xl font-bold">All Events ({this.state.currentEvents.length})</h2>
<ul>{this.state.currentEvents.map(renderSidebarEvent)}</ul> <ul>{this.state.currentEvents.map(renderSidebarEvent)}</ul>
</div> </div>
@ -98,7 +104,14 @@ export default class Calendar extends React.Component {
handleEventClick = (clickInfo) => { handleEventClick = (clickInfo) => {
if (confirm(`Are you sure you want to delete the event '${clickInfo.event.title}'`)) { if (confirm(`Are you sure you want to delete the event '${clickInfo.event.title}'`)) {
axiosInstance
.delete(`todo/${clickInfo.event.id}/`)
.then((response) => {
clickInfo.event.remove(); clickInfo.event.remove();
})
.catch((error) => {
console.error("Error deleting Task:", error);
});
} }
}; };

View File

@ -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 (
<>
<Title>Number of tasks statistics vs. last week</Title>
<AreaChart
className="mt-6"
data={areaChartDataArray}
index="date"
categories={["This Week", "Last Week"]}
colors={["neutral", "indigo"]}
yAxisWidth={30}
showAnimation
/>
</>
);
};

View File

@ -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 (
<>
<Title>Task completed statistics vs. last week</Title>
<BarChart
className="mt-6"
data={barchartDataArray}
index="date"
categories={["This Week", "Last Week"]}
colors={["neutral", "indigo"]}
yAxisWidth={30}
showAnimation
/>
</>
);
};

View File

@ -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 (
<DonutChart
className="mt-6"
data={donutData}
category="count"
index="name"
colors={["rose", "yellow", "orange"]}
showAnimation
radius={25}
/>
);
}

View File

@ -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 (
<Card className="max-w-lg mx-auto">
<Flex alignItems="start">
<div>
<Metric>{kpiCardData.completedThisWeek}</Metric>
</div>
<BadgeDelta deltaType={kpiCardData.incOrdec}>{kpiCardData.percentage.toFixed(0)}%</BadgeDelta>
</Flex>
<Flex className="mt-4">
<Text className="truncate">vs. {kpiCardData.completedLastWeek} (last week)</Text>
</Flex>
<ProgressBar value={kpiCardData.percentage} className="mt-2" />
</Card>
);
}

View File

@ -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 (
<Card className="max-w-lg mx-auto">
<Flex className="flex-col items-center">
<ProgressCircle className="mt-6" value={progressData} size={200} strokeWidth={10} radius={60} color="indigo">
<span className="h-12 w-12 rounded-full bg-indigo-100 flex items-center justify-center text-sm text-indigo-500 font-medium">
{progressData.toFixed(0)} %
</span>
</ProgressCircle>
</Flex>
</Card>
);
}

View File

@ -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 (
<div className="flex flex-col p-12">
<div>
<Title>Dashboard</Title>
<Text>All of your progress will be shown right here.</Text>
<br />
</div>
<div>
<TabGroup className="mt-6">
<TabList>
<Tab>Weekly</Tab>
<Tab>Overview</Tab>
</TabList>
<TabPanels>
{/*Weekly Tab*/}
<TabPanel>
<Grid numItemsMd={2} numItemsLg={3} className="gap-6 mt-6">
<Card>
<Title>Highlights vs. last week</Title>
<br />
<KpiCard />
<br />
<Title>Last week progress rate</Title>
<br />
<ProgressCircleChart />
<Legend
className="mt-3 mx-auto w-1/2"
categories={["Completed Tasks", "Assigned Tasks"]}
colors={["indigo"]}></Legend>
</Card>
<Card>
<BarChartGraph />
</Card>
<Card>
<AreaChartGraph />
</Card>
</Grid>
</TabPanel>
<TabPanel>
<div className="h-31">
<Card className="mx-auto h-full">
<Title>Tasks</Title>
<DonutChartGraph />
<br />
<Legend
className="mt-3 mx-auto w-1/2"
categories={["Todo Task", "Recurrence Task"]}
colors={["rose", "yellow"]}
/>
</Card>
</div>
</TabPanel>
</TabPanels>
</TabGroup>
</div>
</div>
);
}

View File

@ -1,22 +0,0 @@
import React from 'react';
function PlusIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
export default PlusIcon;

View File

@ -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;

View File

@ -1,173 +1,64 @@
import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { SortableContext, useSortable } from "@dnd-kit/sortable";
import TrashIcon from "../icons/trashIcon"; import { AiOutlinePlusCircle } from "react-icons/ai";
import { CSS } from "@dnd-kit/utilities"; import { useMemo } from "react";
import { useMemo, useState } from "react"; import { TaskCard } from "./taskCard";
import PlusIcon from "../icons/plusIcon";
import TaskCard from "./taskCard";
function ColumnContainer({
column,
deleteColumn,
updateColumn,
createTask,
tasks,
deleteTask,
updateTask,
}) {
const [editMode, setEditMode] = useState(false);
export function ColumnContainer({ column, createTask, tasks, deleteTask, updateTask }) {
// Memoize task IDs to prevent unnecessary recalculations
const tasksIds = useMemo(() => { const tasksIds = useMemo(() => {
return tasks.map((task) => task.id); return tasks.map((task) => task.id);
}, [tasks]); }, [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 (
<div <div
ref={setNodeRef}
style={style}
className=" className="
bg-columnBackgroundColor bg-[#f1f2f4]
w-[350px] w-[280px]
h-[500px] max-h-[400px]
max-h-[500px]
rounded-md rounded-md
flex flex
flex-col flex-col
" ">
></div>
);
}
return (
<div
ref={setNodeRef}
style={style}
className="
bg-columnBackgroundColor
w-[350px]
h-[500px]
max-h-[500px]
rounded-md
flex
flex-col
"
>
{/* Column title */} {/* Column title */}
<div <div
{...attributes}
{...listeners}
onClick={() => {
setEditMode(true);
}}
className=" className="
bg-mainBackgroundColor ml-3
text-md text-md
h-[60px]
cursor-grab
rounded-md
rounded-b-none
p-3
font-bold font-bold
border-columnBackgroundColor
border-4
flex flex
items-center items-center
justify-between justify-between
" ">
> <div className="flex gap-2">{column.title}</div>
<div className="flex gap-2">
<div
className="
flex
justify-center
items-center
bg-columnBackgroundColor
px-2
py-1
text-sm
rounded-full
"
></div>
{!editMode && column.title}
{editMode && (
<input
className="bg-white focus:border-rose-500 border rounded outline-none px-2"
value={column.title}
onChange={(e) => updateColumn(column.id, e.target.value)}
autoFocus
onBlur={() => {
setEditMode(false);
}}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
setEditMode(false);
}}
/>
)}
</div>
<button
onClick={() => {
deleteColumn(column.id);
}}
className="
stroke-gray-500
hover:stroke-white
hover:bg-columnBackgroundColor
rounded
px-1
py-2
"
>
<TrashIcon />
</button>
</div> </div>
{/* Column task container */} {/* Column task container */}
<div className="flex flex-grow flex-col gap-4 p-2 overflow-x-hidden overflow-y-auto"> <div className="flex flex-grow flex-col gap-2 p-1 overflow-x-hidden overflow-y-auto">
{/* Provide a SortableContext for the tasks within the column */}
<SortableContext items={tasksIds}> <SortableContext items={tasksIds}>
{/* Render TaskCard for each task in the column */}
{tasks.map((task) => ( {tasks.map((task) => (
<TaskCard <TaskCard
key={task.id} key={task.id}
task={task} task={task}
deleteTask={deleteTask} // Pass deleteTask to TaskCard deleteTask={deleteTask}
updateTask={updateTask} updateTask={updateTask}
// Adjust the useSortable hook for tasks to enable dragging
useSortable={(props) => useSortable({ ...props, disabled: false })}
/> />
))} ))}
</SortableContext> </SortableContext>
</div> </div>
{/* Column footer */} {/* Column footer */}
<button <button
className="flex gap-2 items-center border-columnBackgroundColor border-2 rounded-md p-4 border-x-columnBackgroundColor hover:bg-mainBackgroundColor hover:text-rose-500 active:bg-white" className="flex gap-2 items-center rounded-md p-2 my-2 hover:bg-zinc-200 active:bg-zinc-400"
onClick={() => { onClick={() => {
createTask(column.id); createTask(column.id);
}} }}>
> <AiOutlinePlusCircle />
<PlusIcon />
Add task Add task
</button> </button>
</div> </div>
); );
} }
export default ColumnContainer;

View File

@ -0,0 +1,15 @@
import { ColumnContainer } from "./columnContainer";
export function ColumnContainerCard({ column, createTask, tasks, deleteTask, updateTask }) {
return (
<div className="card bg-[#f1f2f4] shadow border p-1 my-2">
<ColumnContainer
column={column}
createTask={createTask}
tasks={tasks}
deleteTask={deleteTask}
updateTask={updateTask}
/>
</div>
);
}

View File

@ -1,110 +1,20 @@
import PlusIcon from "../icons/plusIcon"; import { useMemo, useState, useEffect } from "react";
import { useMemo, useState } from "react"; import { ColumnContainerCard } from "./columnContainerWrapper";
import ColumnContainer from "./columnContainer"; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { SortableContext, arrayMove } from "@dnd-kit/sortable"; import { SortableContext, arrayMove } from "@dnd-kit/sortable";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import TaskCard from "./taskCard"; import { TaskCard } from "./taskCard";
import { axiosInstance } from "src/api/AxiosConfig";
const defaultCols = [ export function KanbanBoard() {
{ const [columns, setColumns] = useState([]);
id: "todo", const [boardId, setBoardData] = useState();
title: "Todo", const [isLoading, setLoading] = useState(false);
}, const [tasks, setTasks] = useState([]);
{ const [activeTask, setActiveTask] = useState(null);
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);
const columnsId = useMemo(() => columns.map((col) => col.id), [columns]); const columnsId = useMemo(() => columns.map((col) => col.id), [columns]);
const [tasks, setTasks] = useState(defaultTasks); // ---------------- END STATE INITIATE ----------------
const [activeColumn, setActiveColumn] = useState(null);
const [activeTask, setActiveTask] = useState(null);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@ -114,6 +24,140 @@ 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 ( return (
<div <div
className=" className="
@ -121,81 +165,36 @@ function KanbanBoard() {
flex flex
w-full w-full
items-center items-center
justify-center
overflow-x-auto overflow-x-auto
overflow-y-hidden overflow-y-hidden
" ">
> <DndContext sensors={sensors} onDragStart={onDragStart} onDragEnd={onDragEnd} onDragOver={onDragOver}>
<DndContext
sensors={sensors}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
>
<div className="m-auto flex gap-4">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex gap-4">
{!isLoading ? (
<SortableContext items={columnsId}> <SortableContext items={columnsId}>
{columns.map((col) => ( {columns.map((col) => (
<ColumnContainer <ColumnContainerCard
key={col.id} key={col.id}
column={col} column={col}
deleteColumn={deleteColumn}
updateColumn={updateColumn}
createTask={createTask} createTask={createTask}
deleteTask={deleteTask} deleteTask={deleteTask}
updateTask={updateTask} updateTask={updateTask}
tasks={tasks.filter((task) => task.columnId === col.id)} tasks={(tasks || []).filter((task) => task.columnId === col.id)}
/> />
))} ))}{" "}
</SortableContext> </SortableContext>
) : (
<span className="loading loading-dots loading-lg"></span>
)}
</div> </div>
{/* create new column */}
<button
onClick={() => {
createNewColumn();
}}
className="
h-[60px]
w-[350px]
min-w-[350px]
cursor-pointer
rounded-lg
bg-mainBackgroundColor
border-2
border-columnBackgroundColor
p-4
ring-rose-500
hover:ring-2
flex
gap-2
"
>
<PlusIcon />
Add Column
</button>
</div> </div>
{createPortal( {createPortal(
<DragOverlay> <DragOverlay className="bg-white" dropAnimation={null} zIndex={20}>
{activeColumn && ( {/* Render the active task as a draggable overlay */}
<ColumnContainer <TaskCard task={activeTask} deleteTask={deleteTask} updateTask={updateTask} />
column={activeColumn}
deleteColumn={deleteColumn}
updateColumn={updateColumn}
createTask={createTask}
deleteTask={deleteTask}
updateTask={updateTask}
tasks={tasks.filter(
(task) => task.columnId === activeColumn.id
)}
/>
)}
{activeTask && (
<TaskCard
task={activeTask}
deleteTask={deleteTask}
updateTask={updateTask}
/>
)}
</DragOverlay>, </DragOverlay>,
document.body document.body
)} )}
@ -203,135 +202,122 @@ function KanbanBoard() {
</div> </div>
); );
function createTask(columnId) { // Handle the start of a drag event
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);
}
function onDragStart(event) { function onDragStart(event) {
if (event.active.data.current?.type === "Column") { // Check if the dragged item is a Task
setActiveColumn(event.active.data.current.column);
return;
}
if (event.active.data.current?.type === "Task") { if (event.active.data.current?.type === "Task") {
setActiveTask(event.active.data.current.task); setActiveTask(event.active.data.current.task);
return; return;
} }
} }
// Handle the end of a drag event
function onDragEnd(event) { function onDragEnd(event) {
setActiveColumn(null); // Reset active column and task after the drag ends
setActiveTask(null); setActiveTask(null);
const { active, over } = event; const { active, over } = event;
if (!over) return; if (!over) return; // If not dropped over anything, exit
const activeId = active.id; const activeId = active.id;
const overId = over.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 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"; const isOverAColumn = over.data.current?.type === "Column";
// Move tasks between columns and update columnId
if (isActiveATask && isOverAColumn) { if (isActiveATask && isOverAColumn) {
setTasks((tasks) => { setTasks((tasks) => {
const activeIndex = tasks.findIndex((t) => t.id === activeId); 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); return arrayMove(tasks, activeIndex, activeIndex);
}); });
} }
} }
function generateId() { // Helper function to extract the column ID from the element ID
return Math.floor(Math.random() * 10001); 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;
} }
export default KanbanBoard; // 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);
});
}
}
}

View File

@ -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 (
<div className="flex flex-col">
<div className="flex justify-center border-2 py-3 mb-1">
<div>
<div className="tabs tabs-boxed">
<a
id="kanban"
className={`tab ${activeTab === "kanban" ? "tab-active" : ""}`}
onClick={() => handleTabClick("kanban")}>
Kanban
</a>
{/* <a
id="table"
className={`tab ${activeTab === "table" ? "tab-active" : ""}`}
onClick={() => handleTabClick("table")}>
Table
</a> */}
</div>
</div>
</div>
<KanbanBoard />
</div>
);
};

View File

@ -1,26 +1,18 @@
import { useState } from "react"; import { useState } from "react";
import TrashIcon from "../icons/trashIcon"; import { BsFillTrashFill } from "react-icons/bs";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; 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 [mouseIsOver, setMouseIsOver] = useState(false);
const [editMode, setEditMode] = useState(true);
const { const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id: task.id, id: task.id,
data: { data: {
type: "Task", type: "Task",
task, task,
}, },
disabled: editMode,
}); });
const style = { const style = {
@ -28,11 +20,9 @@ function TaskCard({ task, deleteTask, updateTask }) {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
}; };
const toggleEditMode = () => { {
setEditMode((prev) => !prev); /* If card is dragged */
setMouseIsOver(false); }
};
if (isDragging) { if (isDragging) {
return ( return (
<div <div
@ -40,57 +30,38 @@ function TaskCard({ task, deleteTask, updateTask }) {
style={style} style={style}
className=" className="
opacity-30 opacity-30
bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl border-2 border-rose-500 cursor-grab relative bg-mainBackgroundColor p-2.5 items-center flex text-left rounded-xl border-2 border-gray-400 cursor-grab relative
" "
/> />
); );
} }
if (editMode) {
return ( return (
<div <div>
ref={setNodeRef} <TaskDetailModal
style={style} taskId={task.id}
{...attributes} title={task.content}
{...listeners} description={task.description}
className="outline-double outline-1 outline-offset-2 hover:outline-double hover:outline-1 hover:outline-offset-2 bg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative" tags={task.tags}
> difficulty={task.difficulty}
<textarea f challenge={task.challenge}
className=" importance={task.importance}
h-[90%]
w-full resize-none border-none rounded bg-transparent text-black focus:outline-none
"
value={task.content}
autoFocus
placeholder="Task content here"
onBlur={toggleEditMode}
onKeyDown={(e) => {
if (e.key === "Enter" && e.shiftKey) {
toggleEditMode();
}
}}
onChange={(e) => updateTask(task.id, e.target.value)}
/> />
</div>
);
}
return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style}
{...attributes} {...attributes}
{...listeners} {...listeners}
onClick={toggleEditMode} style={style}
className="outline-double outline-1 outline-offset-2 hover:outline-double hover:outline-1 hover:outline-offset-2 sbg-mainBackgroundColor p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-rose-500 cursor-grab relative task" className="justify-center items-center flex text-left rounded-xl cursor-grab relative hover:border-2 hover:border-blue-400 shadow bg-white"
onMouseEnter={() => { onMouseEnter={() => {
setMouseIsOver(true); setMouseIsOver(true);
}} }}
onMouseLeave={() => { onMouseLeave={() => {
setMouseIsOver(false); setMouseIsOver(false);
}} }}>
> <p
<p className="my-auto h-[90%] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap"> className="p-2.5 my-auto w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap rounded-xl shadow bg-white"
onClick={() => document.getElementById(`task_detail_modal_${task.id}`).showModal()}>
{task.content} {task.content}
</p> </p>
@ -99,13 +70,11 @@ function TaskCard({ task, deleteTask, updateTask }) {
onClick={() => { onClick={() => {
deleteTask(task.id); deleteTask(task.id);
}} }}
className="stroke-white absolute right-4 top-1/2 -translate-y-1/2 bg-columnBackgroundColor p-2 rounded opacity-60 hover:opacity-100" className="stroke-white absolute right-0 top-1/2 rounded-full bg-white -translate-y-1/2 bg-columnBackgroundColor p-2 hover:opacity-100 ">
> <BsFillTrashFill />
<TrashIcon />
</button> </button>
)} )}
</div> </div>
</div>
); );
} }
export default TaskCard;

View File

@ -0,0 +1,147 @@
import { useState } from "react";
import { FaTasks, FaRegListAlt } from "react-icons/fa";
import { FaPlus } from "react-icons/fa6";
import { TbChecklist } from "react-icons/tb";
export function TaskDetailModal({ title, description, tags, difficulty, challenge, importance, taskId }) {
const [isChallengeChecked, setChallengeChecked] = useState(challenge);
const [isImportantChecked, setImportantChecked] = useState(importance);
const [currentDifficulty, setCurrentDifficulty] = useState(difficulty);
const handleChallengeChange = () => {
setChallengeChecked(!isChallengeChecked);
};
const handleImportantChange = () => {
setImportantChecked(!isImportantChecked);
};
const handleDifficultyChange = (event) => {
setCurrentDifficulty(parseInt(event.target.value, 10));
};
return (
<dialog id={`task_detail_modal_${taskId}`} className="modal">
<div className="modal-box w-4/5 max-w-3xl">
{/* Title */}
<div className="flex flex-col py-2">
<div className="flex flex-col">
<h3 className="font-bold text-lg">
<span className="flex gap-2">
{<FaTasks className="my-2" />}
{title}
</span>
</h3>
<p className="text-xs">{title}</p>
</div>
</div>
{/* Tags */}
<div className="flex flex-col py-2 pb-4">
<div className="flex flex-row space-x-5">
<div className="dropdown">
<label tabIndex={0} className="btn-md border-2 rounded-xl m-1 py-1">
+ Add Tags
</label>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li>
<a>
<input type="checkbox" checked="checked" className="checkbox checkbox-sm" />
Item 2
</a>
</li>
</ul>
</div>
</div>
<div className="flex flex-nowrap overflow-x-auto"></div>
</div>
{/* Description */}
<div className="flex flex-col gap-2">
<h2 className="font-bold">
<span className="flex gap-2">
<FaRegListAlt className="my-1" />
Description
</span>
</h2>
<textarea className="textarea w-full" disabled>
{description}
</textarea>
</div>
{/* Difficulty, Challenge, and Importance */}
<div className="flex flex-row space-x-3 my-4">
<div className="flex-1 card shadow border-2 p-2">
<input
type="range"
id="difficultySelector"
min={0}
max="100"
value={currentDifficulty}
className="range"
step="25"
onChange={handleDifficultyChange}
/>
<div className="w-full flex justify-between text-xs px-2 space-x-2">
<span>Easy</span>
<span>Normal</span>
<span>Hard</span>
<span>Very Hard</span>
<span>Devil</span>
</div>
</div>
{/* Challenge Checkbox */}
<div className="card shadow border-2 p-2">
<div className="form-control">
<label className="label cursor-pointer space-x-2">
<span className="label-text">Challenge</span>
<input
type="checkbox"
checked={isChallengeChecked}
className="checkbox"
onChange={handleChallengeChange}
/>
</label>
</div>
</div>
{/* Important Checkbox */}
<div className="card shadow border-2 p-2">
<div className="form-control">
<label className="label cursor-pointer space-x-2">
<span className="label-text">Important</span>
<input
type="checkbox"
checked={isImportantChecked}
className="checkbox"
onChange={handleImportantChange}
/>
</label>
</div>
</div>
</div>
{/* Subtask */}
<div className="flex flex-col pt-2">
<h2 className="font-bold">
<span className="flex gap-1">
<TbChecklist className="my-1" />
Subtasks
</span>
</h2>
<div className="flex space-x-3 pt-2">
<input type="text" placeholder="subtask topic" className="input input-bordered flex-1 w-full" />
<button className="btn">
<FaPlus />
Add Subtask
</button>
</div>
</div>
<form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">X</button>
</form>
</div>
</dialog>
);
}

View File

@ -0,0 +1,44 @@
import { FloatingParticles } from "../FlaotingParticles";
export function LandingPage() {
return (
<div className="h-screen flex items-center justify-center bg-gradient-to-r from-zinc-100 via-gray-200 to-zinc-100">
{/* Particles Container */}
<FloatingParticles />
{/* Navbar */}
<div className="relative" id="home">
<div className="max-w-7xl mx-auto px-6 md:px-12 xl:px-6">
<div className="relative pt-36 ml-auto">
<div className="lg:w-2/3 text-center mx-auto">
<h1 className="text-#143D6C font-bold text-5xl md:text-6xl xl:text-7xl">
Manage your task with{" "}
<span className="text-primary">
TurTask
<label className="swap swap-flip text-6xl">
<input type="checkbox" />
<div className="swap-on">😇</div>
<div className="swap-off">🥳</div>
</label>
</span>
</h1>
<p className="mt-8 text-#143D6C">
Unleash productivity with our personal task and project
management.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-y-4 gap-x-6">
<a
href="/login"
className="relative flex h-11 w-full items-center justify-center px-6 before:absolute before:inset-0 before:rounded-full before:bg-primary before:transition before:duration-300 hover:before:scale-105 active:duration-75 active:before:scale-95 sm:w-max"
>
<span className="relative text-base font-semibold text-white">
Get started
</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,36 +1,24 @@
import { useState } from "react"; import { useState } from "react";
import { import { AiOutlineHome, AiOutlineSchedule, AiOutlineUnorderedList } from "react-icons/ai";
AiOutlineHome, import { PiStepsDuotone } from "react-icons/pi";
AiOutlineSchedule, import { IoSettingsOutline } from "react-icons/io5";
AiOutlineUnorderedList,
AiOutlinePieChart,
AiOutlinePlus,
} from "react-icons/ai";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Link, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
const menuItems = [ const menuItems = [
{ id: 0, path: "/", icon: <AiOutlineHome /> }, { id: 0, path: "/", icon: <AiOutlineHome /> },
{ id: 1, path: "/tasks", icon: <AiOutlineUnorderedList /> }, { id: 1, path: "/tasks", icon: <AiOutlineUnorderedList /> },
{ id: 2, path: "/calendar", icon: <AiOutlineSchedule /> }, { id: 2, path: "/calendar", icon: <AiOutlineSchedule /> },
{ id: 3, path: "/analytic", icon: <AiOutlinePieChart /> }, { id: 3, path: "/priority", icon: <PiStepsDuotone /> },
{ id: 4, path: "/priority", icon: <AiOutlinePlus /> },
]; ];
// { id: 3, path: "/settings", icon: <IoSettingsOutline /> },
const IconSideNav = () => { export const SideNav = () => {
return (
<div className="bg-slate-900 text-slate-100 flex">
<SideNav />
</div>
);
};
const SideNav = () => {
const [selected, setSelected] = useState(0); const [selected, setSelected] = useState(0);
return ( return (
<nav className="bg-slate-950 p-4 flex flex-col items-center gap-2 h-screen"> <nav className="bg-slate-950 p-4 flex flex-col items-center gap-2 h-full fixed top-0 left-0 z-50">
{menuItems.map(item => ( {menuItems.map((item) => (
<NavItem <NavItem
key={item.id} key={item.id}
icon={item.icon} icon={item.icon}
@ -44,12 +32,12 @@ const SideNav = () => {
); );
}; };
const NavItem = ({ icon, selected, id, setSelected, logo, path }) => { const NavItem = ({ icon, selected, id, setSelected, path }) => {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<motion.button <motion.button
className="p-3 text-xl bg-slate-800 hover-bg-slate-700 rounded-md transition-colors relative" className="p-3 text-xl text-white bg-slate-800 hover-bg-slate-700 rounded-md transition-colors relative"
onClick={() => { onClick={() => {
setSelected(id); setSelected(id);
navigate(path); navigate(path);
@ -69,5 +57,3 @@ const NavItem = ({ icon, selected, id, setSelected, logo, path }) => {
</motion.button> </motion.button>
); );
}; };
export default IconSideNav;

Some files were not shown because too many files have changed in this diff Show More