├── backend ├── boards │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── test_models.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0012_auto_20201023_0514.py │ │ ├── 0007_starredboard_squashed_0008_delete_starredboard.py │ │ ├── 0008_comment_created_at.py │ │ ├── 0009_auto_20201022_0848.py │ │ ├── 0010_auto_20201022_1158.py │ │ ├── 0013_auto_20201023_1019.py │ │ ├── 0004_auto_20200924_0558.py │ │ ├── 0011_auto_20201022_1844.py │ │ ├── 0002_auto_20200920_2158.py │ │ ├── 0003_auto_20200923_1024_squashed_0005_auto_20200923_1030.py │ │ ├── 0006_auto_20201019_1644_squashed_0007_auto_20201019_1644.py │ │ ├── 0005_notification_squashed_0007_auto_20201014_0724.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── permissions.py │ ├── urls.py │ ├── signals.py │ ├── serializers.py │ └── models.py ├── projects │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ └── test_models.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0008_remove_project_profile_picture.py │ │ ├── 0007_auto_20200923_1530.py │ │ ├── 0005_auto_20200922_1019.py │ │ ├── 0003_auto_20200920_1512.py │ │ ├── 0006_auto_20200922_1657.py │ │ ├── 0001_initial.py │ │ ├── 0002_projectmembership.py │ │ └── 0004_auto_20200920_1853_squashed_0006_project_members.py │ ├── admin.py │ ├── apps.py │ ├── signals.py │ ├── permissions.py │ ├── urls.py │ ├── models.py │ └── serializers.py ├── trello │ ├── __init__.py │ ├── asgi.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── users │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_models.py │ │ └── test_views.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0003_user_starred_boards.py │ │ ├── 0002_auto_20200921_1408.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── views.py │ ├── auth.py │ ├── serializers.py │ ├── admin.py │ └── models.py ├── pytest.ini ├── .coveragerc ├── Pipfile └── manage.py ├── frontend ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── manifest.json │ └── index.html ├── src │ ├── static │ │ ├── img │ │ │ ├── 404.jpg │ │ │ ├── bg1.jpg │ │ │ ├── bg2.jpg │ │ │ ├── bg3.jpg │ │ │ ├── bg4.jpg │ │ │ ├── bg5.jpg │ │ │ ├── bg6.jpg │ │ │ ├── logo.png │ │ │ ├── logo2.png │ │ │ ├── slideshow-1.jpg │ │ │ ├── teambg.svg │ │ │ └── greenface.svg │ │ ├── css │ │ │ ├── base │ │ │ │ ├── _variables.scss │ │ │ │ ├── _typography.scss │ │ │ │ └── _reset.scss │ │ │ ├── index.scss │ │ │ └── components │ │ │ │ ├── _error-page.scss │ │ │ │ ├── _landing.scss │ │ │ │ ├── _notification.scss │ │ │ │ ├── _search.scss │ │ │ │ ├── _addboard-modal.scss │ │ │ │ ├── _createteam.scss │ │ │ │ ├── _login.scss │ │ │ │ ├── _common.scss │ │ │ │ ├── _header.scss │ │ │ │ ├── _home.scss │ │ │ │ ├── _project.scss │ │ │ │ ├── _modals.scss │ │ │ │ └── _board.scss │ │ └── js │ │ │ └── const.js │ ├── context │ │ ├── globalContext.js │ │ ├── globalReducer.js │ │ └── GlobalProvider.js │ ├── hooks │ │ ├── useDocumentTitle.js │ │ ├── useBlurSetState.js │ │ └── useAxiosGet.js │ ├── components │ │ ├── boards │ │ │ ├── Labels.js │ │ │ ├── SearchedBoard.js │ │ │ ├── ProfilePic.js │ │ │ ├── DraggableCard.js │ │ │ └── HomeBoard.js │ │ ├── modals │ │ │ ├── AuthError.js │ │ │ ├── SearchModal.js │ │ │ ├── InviteMembersModal.js │ │ │ ├── ChangePermissionsModal.js │ │ │ ├── BoardBackground.js │ │ │ ├── CreateTeamModal.js │ │ │ ├── NotificationsModal.js │ │ │ └── AddBoardModal.js │ │ ├── headers │ │ │ ├── LandingHeader.js │ │ │ └── Header.js │ │ ├── auth │ │ │ ├── LoginForm.js │ │ │ └── RegisterForm.js │ │ └── sidebars │ │ │ └── HomeSidebar.js │ ├── index.js │ ├── pages │ │ ├── Landing.js │ │ ├── Error404.js │ │ └── Login.js │ ├── App.js │ └── serviceWorker.js ├── .gitignore ├── package.json └── README.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md └── .gitignore /backend/boards/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/projects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/trello/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/users/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/boards/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/users/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/boards/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/projects/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/users/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/projects/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/static/img/404.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/404.jpg -------------------------------------------------------------------------------- /frontend/src/static/img/bg1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/bg1.jpg -------------------------------------------------------------------------------- /frontend/src/static/img/bg2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/bg2.jpg -------------------------------------------------------------------------------- /frontend/src/static/img/bg3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/bg3.jpg -------------------------------------------------------------------------------- /frontend/src/static/img/bg4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/bg4.jpg -------------------------------------------------------------------------------- /frontend/src/static/img/bg5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/bg5.jpg -------------------------------------------------------------------------------- /frontend/src/static/img/bg6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/bg6.jpg -------------------------------------------------------------------------------- /frontend/src/static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/logo.png -------------------------------------------------------------------------------- /frontend/src/static/img/logo2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/logo2.png -------------------------------------------------------------------------------- /backend/users/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UsersConfig(AppConfig): 5 | name = 'users' 6 | -------------------------------------------------------------------------------- /frontend/src/static/img/slideshow-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vdevired/trello-clone/HEAD/frontend/src/static/img/slideshow-1.jpg -------------------------------------------------------------------------------- /backend/boards/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BoardsConfig(AppConfig): 5 | name = 'boards' 6 | 7 | def ready(self): 8 | import boards.signals 9 | -------------------------------------------------------------------------------- /backend/projects/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Project, ProjectMembership 3 | 4 | admin.site.register(Project) 5 | admin.site.register(ProjectMembership) -------------------------------------------------------------------------------- /backend/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = trello.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | addopts = -p no:warnings --nomigrations --cov=. --cov-report=html -------------------------------------------------------------------------------- /backend/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | *apps.py, 4 | *migrations/*, 5 | *settings*, 6 | *tests/*, 7 | *urls.py, 8 | *wsgi.py, 9 | manage.py, 10 | *admin.py 11 | *__init__.py 12 | -------------------------------------------------------------------------------- /backend/projects/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ProjectsConfig(AppConfig): 5 | name = 'projects' 6 | 7 | def ready(self): 8 | import projects.signals 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## How to Contribute 2 | - Fork the the project 3 | - Create your feature branch (git checkout -b my-new-feature) 4 | - Make required changes and commit (git commit -am 'Add some feature') 5 | - Push to the branch (git push origin my-new-feature) 6 | - Create new Pull Request 7 | -------------------------------------------------------------------------------- /frontend/src/static/css/base/_variables.scss: -------------------------------------------------------------------------------- 1 | $blue: #4680ff; 2 | $light-blue: #d9e8ff; 3 | $red: red; 4 | $light-red: #ffe2e9; 5 | $yellow: #ffb64d; 6 | $light-yellow: #fff1db; 7 | 8 | $border-color: rgba(211, 211, 211, 0.55); 9 | $grey: #f6f7fb; // Default board BG color 10 | $secondary-text: #838282; 11 | -------------------------------------------------------------------------------- /backend/boards/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Board, List, Item, Label, Comment, Attachment, Notification 3 | 4 | admin.site.register(Board) 5 | admin.site.register(List) 6 | admin.site.register(Item) 7 | admin.site.register(Label) 8 | admin.site.register(Comment) 9 | admin.site.register(Attachment) 10 | admin.site.register(Notification) 11 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Trello", 3 | "name": "Trello", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /backend/projects/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | from .models import Project, ProjectMembership 4 | 5 | @receiver(post_save, sender=Project) 6 | def create_project_owner_membership(sender, instance, created, **kwargs): 7 | if created: 8 | ProjectMembership.objects.create(member=instance.owner, project=instance, access_level=2) -------------------------------------------------------------------------------- /frontend/src/static/css/base/_typography.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;900&display=swap'); 2 | @import url('https://pro.fontawesome.com/releases/v5.10.0/css/all.css'); 3 | 4 | body { 5 | font-family: 'Montserrat', sans-serif; 6 | } 7 | 8 | button, input, select, textarea, input::placeholder { 9 | font-family: 'Montserrat', sans-serif; 10 | } -------------------------------------------------------------------------------- /frontend/src/static/js/const.js: -------------------------------------------------------------------------------- 1 | export const backendUrl = "http://localhost:8000"; 2 | 3 | export const colors = [ 4 | ["#0079bf", false], 5 | ["#70b500", false], 6 | ["#ff9f1a", false], 7 | ["#eb5a46", false], 8 | ["#f2d600", false], 9 | ["#c377e0", false], 10 | ["#ff78cb", false], 11 | ["#00c2e0", false], 12 | ["#51e898", false], 13 | ["#c4c9cc", false], 14 | ]; 15 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /frontend/src/context/globalContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default React.createContext({ 4 | authUser: null, 5 | checkedAuth: false, // Whether your auth has been checked or not 6 | // The below two are to reduce prop drilling to List and Card 7 | board: null, // The board we are currently viewing, via useAxiosGet in Board.js. 8 | setBoard: null, // The setter returned by useAxioGet in Board.js 9 | }); 10 | -------------------------------------------------------------------------------- /backend/Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | djangorestframework-simplejwt = "*" 10 | pillow = "*" 11 | pytest = "*" 12 | pytest-django = "*" 13 | pytest-cov = "*" 14 | mixer = "*" 15 | drf-yasg = "*" 16 | uritemplate = "*" 17 | django-cors-headers = "*" 18 | djangorestframework = "*" 19 | pyyaml = "*" 20 | redis = "*" 21 | 22 | [requires] 23 | python_version = "3.8" 24 | -------------------------------------------------------------------------------- /backend/projects/migrations/0008_remove_project_profile_picture.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-11 04:16 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('projects', '0007_auto_20200923_1530'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveField( 14 | model_name='project', 15 | name='profile_picture', 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /backend/trello/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for trello project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trello.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /backend/trello/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for trello project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trello.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /backend/boards/migrations/0012_auto_20201023_0514.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-23 05:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('boards', '0011_auto_20201022_1844'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='label', 15 | name='title', 16 | field=models.CharField(blank=True, max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDocumentTitle.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from "react"; 2 | 3 | const useDocumentTitle = (title, retainOnUnmount = false) => { 4 | const defaultTitle = useRef(document.title).current; 5 | 6 | useEffect(() => { 7 | document.title = title; 8 | }, [title]); 9 | 10 | useEffect(() => { 11 | return () => { 12 | if (!retainOnUnmount) { 13 | document.title = defaultTitle; 14 | } 15 | }; 16 | }, []); 17 | }; 18 | 19 | export default useDocumentTitle; 20 | -------------------------------------------------------------------------------- /backend/projects/migrations/0007_auto_20200923_1530.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-23 15:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('projects', '0006_auto_20200922_1657'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='projectmembership', 15 | name='access_level', 16 | field=models.IntegerField(choices=[(1, 'Member'), (2, 'Admin')], default=1), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/projects/migrations/0005_auto_20200922_1019.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-22 10:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('projects', '0004_auto_20200920_1853_squashed_0006_project_members'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='projectmembership', 15 | name='access_level', 16 | field=models.IntegerField(choices=[(1, 'Member'), (2, 'Admin')]), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/users/migrations/0003_user_starred_boards.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-20 10:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('boards', '0008_delete_starredboard'), 10 | ('users', '0002_auto_20200921_1408'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='user', 16 | name='starred_boards', 17 | field=models.ManyToManyField(blank=True, to='boards.Board'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /frontend/src/static/img/teambg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path 5 | Created with Sketch. 6 | 7 | Path 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /backend/boards/migrations/0007_starredboard_squashed_0008_delete_starredboard.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-20 11:04 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | replaces = [('boards', '0007_starredboard'), ('boards', '0008_delete_starredboard')] 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('boards', '0006_auto_20201019_1644_squashed_0007_auto_20201019_1644'), 14 | ] 15 | 16 | operations = [ 17 | ] 18 | -------------------------------------------------------------------------------- /backend/boards/migrations/0008_comment_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-21 08:42 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('boards', '0007_starredboard_squashed_0008_delete_starredboard'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='comment', 16 | name='created_at', 17 | field=models.DateTimeField(default=django.utils.timezone.now), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/projects/migrations/0003_auto_20200920_1512.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-20 15:12 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ('projects', '0002_projectmembership'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterUniqueTogether( 16 | name='projectmembership', 17 | unique_together={('project', 'member')}, 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/boards/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | from .models import Board 3 | 4 | 5 | class CanViewBoard(permissions.BasePermission): 6 | def has_object_permission(self, request, view, obj): 7 | return request.user.can_view_board(obj) 8 | 9 | 10 | class IsAuthorOrReadOnly(CanViewBoard): 11 | def has_object_permission(self, request, view, obj): 12 | if request.method in permissions.SAFE_METHODS: 13 | return super().has_object_permission(request, view, obj.item.list.board) 14 | else: 15 | return request.user == obj.author 16 | -------------------------------------------------------------------------------- /frontend/src/static/css/index.scss: -------------------------------------------------------------------------------- 1 | @import "base/_reset"; 2 | @import "base/_variables"; 3 | @import "base/_typography"; 4 | 5 | @import "components/_common"; 6 | @import "components/_header"; 7 | @import "components/_board"; 8 | @import "components/_edit"; 9 | @import "components/_search"; 10 | @import "components/_landing"; 11 | @import "components/_home"; 12 | @import "components/_createteam"; 13 | @import "components/_login"; 14 | @import "components/_modals"; 15 | @import "components/_addboard-modal"; 16 | @import "components/project"; 17 | @import "components/_error-page"; 18 | @import "components/_notification"; 19 | -------------------------------------------------------------------------------- /frontend/src/components/boards/Labels.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | const Labels = ({ labels }) => { 5 | if (labels.length === 0) return null; 6 | return ( 7 |
8 | {labels.map((label) => ( 9 |

14 | ___ 15 |

16 | ))} 17 |
18 | ); 19 | }; 20 | 21 | export default Labels; 22 | -------------------------------------------------------------------------------- /frontend/src/context/globalReducer.js: -------------------------------------------------------------------------------- 1 | export const LOGIN = "LOGIN"; 2 | export const LOGOUT = "LOGOUT"; 3 | export const SET_BOARD_CONTEXT = "SET_BOARD_CONTEXT"; 4 | 5 | export const globalReducer = (state, action) => { 6 | switch (action.type) { 7 | case LOGIN: 8 | return { ...state, authUser: action.user, checkedAuth: true }; 9 | case LOGOUT: 10 | return { ...state, authUser: null, checkedAuth: true }; 11 | case SET_BOARD_CONTEXT: 12 | return { ...state, board: action.board, setBoard: action.setBoard }; 13 | default: 14 | return state; 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/components/boards/SearchedBoard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from "react-router-dom"; 3 | 4 | const SearchedBoard = ({board, setShowModal}) => ( 5 | 6 | setShowModal(false)} to={`/b/${board.id}`} class="searched-board searched-board--red"> 7 |

{board.title}

8 |
9 |

{`${board.list_count} ${board.list_count === 1 ? 'list' : 'lists'}`}

10 |

{`${board.item_count} ${board.item_count === 1 ? 'card' : 'cards'}`}

11 |
12 | 13 | 14 | ) 15 | 16 | export default SearchedBoard; -------------------------------------------------------------------------------- /backend/boards/migrations/0009_auto_20201022_0848.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-22 08:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('boards', '0008_comment_created_at'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='item', 15 | name='color', 16 | field=models.CharField(blank=True, max_length=6), 17 | ), 18 | migrations.AddField( 19 | model_name='item', 20 | name='image_url', 21 | field=models.URLField(blank=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/boards/migrations/0010_auto_20201022_1158.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-22 11:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('boards', '0009_auto_20201022_0848'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='board', 15 | name='color', 16 | field=models.CharField(blank=True, max_length=6), 17 | ), 18 | migrations.AddField( 19 | model_name='board', 20 | name='image_url', 21 | field=models.URLField(blank=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/users/migrations/0002_auto_20200921_1408.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-21 14:08 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('users', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name='user', 16 | name='username', 17 | field=models.CharField(max_length=15, unique=True, validators=[django.core.validators.RegexValidator('^[a-zA-Z0-9_\\.]*$', 'Only alphanumeric characters, underscores, and periods are allowed in your username.')]), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /backend/boards/migrations/0013_auto_20201023_1019.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-23 10:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('boards', '0012_auto_20201023_0514'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='item', 15 | name='order', 16 | field=models.DecimalField(blank=True, decimal_places=15, max_digits=30, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='list', 20 | name='order', 21 | field=models.DecimalField(blank=True, decimal_places=15, max_digits=30, null=True), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /backend/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'trello.settings') 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == '__main__': 22 | main() 23 | -------------------------------------------------------------------------------- /frontend/src/hooks/useBlurSetState.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useCallback } from "react"; 2 | 3 | // Mutate state if click occurs outside element with className 4 | const useBlurSetState = (className, state, setState) => { 5 | const handleClick = useCallback((e) => { 6 | const elem = document.querySelector(className); 7 | if (!elem) { 8 | setState(false); 9 | return; 10 | } 11 | if (!elem.contains(e.target)) setState(false); 12 | }, []); 13 | 14 | useEffect(() => { 15 | if (state) { 16 | document.addEventListener("click", handleClick); 17 | } else { 18 | document.removeEventListener("click", handleClick); 19 | } 20 | }, [state]); 21 | }; 22 | 23 | export default useBlurSetState; 24 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './static/css/index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | import { BrowserRouter } from "react-router-dom"; 8 | import GlobalProvider from "./context/GlobalProvider"; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById('root') 19 | ); 20 | 21 | // If you want your app to work offline and load faster, you can change 22 | // unregister() to register() below. Note this comes with some pitfalls. 23 | // Learn more about service workers: https://bit.ly/CRA-PWA 24 | serviceWorker.unregister(); 25 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_error-page.scss: -------------------------------------------------------------------------------- 1 | .error-page { 2 | position: relative; // So we can postion children absolutely 3 | 4 | &__image { 5 | object-fit: cover; 6 | width: 100vw; 7 | height: 100vh; 8 | filter: brightness(80%); 9 | } 10 | 11 | &__content { 12 | color: white; 13 | position: absolute; 14 | top: 50%; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | text-align: center; 18 | } 19 | 20 | &__title { 21 | font-size: 3rem; 22 | font-weight: 700; 23 | margin-bottom: 0.5em; 24 | } 25 | 26 | &__subtitle { 27 | width: 70%; 28 | margin: 0 auto 2em; 29 | } 30 | 31 | &__link { 32 | color: $blue; 33 | text-decoration: underline; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /backend/boards/migrations/0004_auto_20200924_0558.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-24 05:58 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ('boards', '0003_auto_20200923_1024_squashed_0005_auto_20200923_1030'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='item', 17 | name='assigned_to', 18 | field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), 19 | ), 20 | migrations.AddField( 21 | model_name='item', 22 | name='due_date', 23 | field=models.DateTimeField(blank=True, null=True), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /backend/projects/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions 2 | from projects.models import ProjectMembership 3 | 4 | 5 | class IsProjectAdminOrMemberReadOnly(permissions.BasePermission): 6 | def has_object_permission(self, request, view, obj): 7 | try: 8 | pmem = ProjectMembership.objects.get(member=request.user, project=obj) 9 | except ProjectMembership.DoesNotExist: 10 | return False 11 | 12 | if request.method in permissions.SAFE_METHODS: 13 | return True 14 | return pmem.access_level == 2 15 | 16 | class IsProjectMember(permissions.BasePermission): 17 | def has_object_permission(self, request, view, obj): 18 | try: 19 | pmem = ProjectMembership.objects.get(member=request.user, project=obj) 20 | except ProjectMembership.DoesNotExist: 21 | return False 22 | return True -------------------------------------------------------------------------------- /backend/users/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework import permissions, status 2 | from rest_framework.response import Response 3 | from rest_framework.views import APIView 4 | 5 | from .serializers import UserSerializer 6 | 7 | class RegisterUser(APIView): 8 | permission_classes = (permissions.AllowAny,) 9 | serializer_class = UserSerializer 10 | 11 | def post(self, request, format=None): 12 | serializer = UserSerializer(data=request.data) 13 | if serializer.is_valid(): 14 | user = serializer.save() 15 | return Response(serializer.data, status=status.HTTP_201_CREATED) 16 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) 17 | 18 | class Me(APIView): 19 | serializer_class = UserSerializer 20 | 21 | def get(self, request, format=None): 22 | serializer = UserSerializer(request.user) 23 | return Response(serializer.data) 24 | -------------------------------------------------------------------------------- /backend/boards/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from .views import BoardDetail, BoardList, BoardStar, ItemList, ItemDetail, ListShow, ListDetail, LabelList, LabelDetail, CommentList, CommentDetail, AttachmentDetail, AttachmentList 3 | 4 | urlpatterns = [ 5 | path('', BoardList.as_view()), 6 | path('/', BoardDetail.as_view()), 7 | path('star/', BoardStar.as_view()), 8 | path('items/', ItemList.as_view()), 9 | path('items//', ItemDetail.as_view()), 10 | path('lists/', ListShow.as_view()), 11 | path('lists//', ListDetail.as_view()), 12 | path('labels/', LabelList.as_view()), 13 | path('labels//', LabelDetail.as_view()), 14 | path('comments/', CommentList.as_view()), 15 | path('comments//', CommentDetail.as_view()), 16 | path('attachments/', AttachmentList.as_view()), 17 | path('attachments//', AttachmentDetail.as_view()), 18 | ] 19 | -------------------------------------------------------------------------------- /backend/projects/migrations/0006_auto_20200922_1657.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-22 16:57 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('projects', '0005_auto_20200922_1019'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='projectmembership', 18 | name='member', 19 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), 20 | ), 21 | migrations.AlterField( 22 | model_name='projectmembership', 23 | name='project', 24 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project'), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /backend/boards/migrations/0011_auto_20201022_1844.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-22 18:44 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('boards', '0010_auto_20201022_1158'), 11 | ] 12 | 13 | operations = [ 14 | migrations.RemoveField( 15 | model_name='label', 16 | name='item', 17 | ), 18 | migrations.AddField( 19 | model_name='item', 20 | name='labels', 21 | field=models.ManyToManyField(blank=True, to='boards.Label'), 22 | ), 23 | migrations.AddField( 24 | model_name='label', 25 | name='board', 26 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='boards.board'), 27 | preserve_default=False, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /backend/users/auth.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.contrib.auth.backends import ModelBackend 3 | 4 | # Whatever the user types into the username field is passed into the standard backend and then here 5 | # If my email is another user's username and I try logging in with my email 6 | # It will match the other user's account before mine as the standard authentication backend is used first 7 | # So I can only log in with my username 8 | # Hence, we will now allow '@' in usernames - peep the RegexValidator in models.py 9 | 10 | class EmailBackend(ModelBackend): 11 | def authenticate(self, request, username=None, password=None, **kwargs): 12 | UserModel = get_user_model() 13 | try: 14 | user = UserModel.objects.get(email=username) 15 | except UserModel.DoesNotExist: 16 | return None 17 | else: 18 | if user.check_password(password): 19 | return user 20 | return None -------------------------------------------------------------------------------- /backend/boards/migrations/0002_auto_20200920_2158.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-20 16:28 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('boards', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='item', 16 | name='list', 17 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='items', to='boards.list'), 18 | preserve_default=False, 19 | ), 20 | migrations.AddField( 21 | model_name='item', 22 | name='order', 23 | field=models.IntegerField(blank=True, null=True), 24 | ), 25 | migrations.AddField( 26 | model_name='list', 27 | name='order', 28 | field=models.IntegerField(blank=True, null=True), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /backend/projects/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mixer.backend.django import mixer 4 | from projects.models import Project, ProjectMembership 5 | from users.models import User 6 | 7 | @pytest.mark.django_db 8 | class TestProject: 9 | def test_project_create(self): 10 | user = mixer.blend(User, username='test') 11 | proj = mixer.blend(Project, owner = user) 12 | assert proj.owner == user 13 | 14 | def test_project_str(self): 15 | proj = mixer.blend(Project) 16 | assert str(proj) == proj.title 17 | 18 | @pytest.mark.django_db 19 | class TestProjectMembers: 20 | def test_member(self): 21 | proj = mixer.blend(Project) 22 | user = mixer.blend(User, username='test') 23 | mixer.blend(ProjectMembership, member=user, project=proj) 24 | assert proj.members.get(username='test') == user 25 | 26 | def test_proj_member_str(self): 27 | pmem = mixer.blend(ProjectMembership) 28 | assert str(pmem) == f'{pmem.member.full_name} , {pmem.project.title}' -------------------------------------------------------------------------------- /frontend/src/components/boards/ProfilePic.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const hashName = (str) => { 4 | let res = 0; 5 | for (let i = 0; i < str.length; i++) { 6 | res += str.charCodeAt(i); 7 | } 8 | 9 | return res + 1; // So my name maps to blue 10 | }; 11 | 12 | const colors = ["red", "yellow", "blue"]; 13 | 14 | const getNameColor = (name) => { 15 | return colors[hashName(name) % colors.length]; 16 | }; 17 | 18 | const ProfilePic = ({ user, large }) => 19 | user.profile_pic ? ( 20 |
21 | 22 |
23 | ) : ( 24 |
29 | {user.full_name.substring(0, 1)} 30 |
31 | ); 32 | 33 | ProfilePic.defaultProps = { 34 | large: false, 35 | }; 36 | 37 | export default ProfilePic; 38 | -------------------------------------------------------------------------------- /backend/users/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from .models import User 3 | 4 | class UserSerializer(serializers.ModelSerializer): 5 | class Meta: 6 | model = User 7 | fields = ('username', 'email', 'first_name', 'last_name', 'full_name', 'profile_pic', 'password') 8 | read_only_fields = ('full_name',) 9 | extra_kwargs = {'password': {'write_only': True}, 10 | 'first_name': {'write_only': True}, 11 | 'last_name': {'write_only': True}} 12 | 13 | 14 | def create(self, validated_data): 15 | user = User.objects.create( 16 | username = validated_data['username'], 17 | email = validated_data['email'], 18 | first_name = validated_data['first_name'], 19 | last_name = validated_data['last_name'] 20 | ) 21 | 22 | if 'profile_pic' in validated_data: 23 | user.profile_pic = validated_data['profile_pic'] 24 | 25 | user.set_password(validated_data['password']) 26 | user.save() 27 | 28 | return user -------------------------------------------------------------------------------- /backend/users/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin 3 | 4 | from .models import User 5 | 6 | 7 | class BoardsInline(admin.TabularInline): 8 | model = User.starred_boards.through 9 | verbose_name = "Starred Board" 10 | verbose_name_plural = "Starred Boards" 11 | extra = 0 12 | 13 | 14 | class UserAdmin(DjangoUserAdmin): 15 | fieldsets = ( 16 | (('Basic Info'), {'fields': ('username', 'email', 'first_name', 17 | 'last_name', 'profile_pic', 'password')}), 18 | (('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', 19 | 'groups', 'user_permissions')}), 20 | (('Important dates'), {'fields': ('last_login', 'date_joined')}), 21 | ) 22 | 23 | add_fieldsets = ( 24 | (None, {'fields': ('username', 'email', 'first_name', 25 | 'last_name', 'profile_pic', 'password1', 'password2')}), 26 | ) 27 | 28 | inlines = [BoardsInline] 29 | 30 | 31 | admin.site.register(User, UserAdmin) 32 | -------------------------------------------------------------------------------- /frontend/src/components/modals/AuthError.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from "react"; 2 | import {modalBlurHandler} from "../../static/js/util"; 3 | 4 | const capitalize = (s) => { 5 | return s[0].toUpperCase() + s.slice(1).toLowerCase(); 6 | }; 7 | 8 | const AuthError = ({ signup, msgs, position, setErrMsgs}) => { 9 | useEffect(modalBlurHandler(setErrMsgs), []) 10 | 11 | position.position = 'fixed'; 12 | return ( 13 |
14 |
15 | { signup?

Error Signing up

:

Error Logging in

16 | } 17 | setErrMsgs({msgs: msgs, err: false})}> 18 |
19 |
    20 | { Object.keys(msgs).map((k,v) => ( 21 |
  • 22 | {capitalize(k.toString())}:{" "} 23 | {capitalize(msgs[k].toString())} 24 |
  • 25 | ))} 26 |
27 |
28 | ); 29 | }; 30 | 31 | export default AuthError; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Vikhyat Devireddy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/pages/Landing.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import useDocumentTitle from "../hooks/useDocumentTitle"; 4 | 5 | import bgImage from "../static/img/bg1.jpg"; 6 | 7 | const Landing = () => { 8 | useDocumentTitle("Trello"); 9 | return ( 10 |
11 | 12 |
13 |

14 | Trello lets you work more collaboratively and get more done 15 |

16 |

17 | Trello's boards, lists, and cards enable you to organize and 18 | prioritize your projects in a fun, flexible, and rewarding 19 | way. 20 |

21 | 22 | Sign Up For Free 23 | 24 |
25 |
26 | ); 27 | }; 28 | 29 | export default Landing; 30 | -------------------------------------------------------------------------------- /frontend/src/components/boards/DraggableCard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Draggable } from "react-beautiful-dnd"; 3 | import Card from "./Card"; 4 | 5 | const DraggableCard = ({ card, list, index }) => { 6 | return ( 7 | 8 | {(provided, snapshot) => { 9 | if ( 10 | typeof provided.draggableProps.onTransitionEnd === 11 | "function" 12 | ) { 13 | const anim = window?.requestAnimationFrame(() => 14 | provided.draggableProps.onTransitionEnd({ 15 | propertyName: "transform", 16 | }) 17 | ); 18 | } 19 | return ( 20 | 26 | ); 27 | }} 28 | 29 | ); 30 | }; 31 | 32 | export default DraggableCard; 33 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "axios": "^0.20.0", 10 | "dotenv": "^8.2.0", 11 | "humanize-duration": "^3.24.0", 12 | "lodash": "^4.17.20", 13 | "qs": "^6.9.4", 14 | "react": "^16.13.1", 15 | "react-beautiful-dnd": "^13.0.0", 16 | "react-dom": "^16.13.1", 17 | "react-hook-form": "^6.9.0", 18 | "react-router-dom": "^5.2.0", 19 | "react-scripts": "3.4.3", 20 | "uuid": "^8.3.1" 21 | }, 22 | "scripts": { 23 | "start": "react-scripts start", 24 | "build": "react-scripts build", 25 | "test": "react-scripts test", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": "react-app" 30 | }, 31 | "browserslist": { 32 | "production": [ 33 | ">0.2%", 34 | "not dead", 35 | "not op_mini all" 36 | ], 37 | "development": [ 38 | "last 1 chrome version", 39 | "last 1 firefox version", 40 | "last 1 safari version" 41 | ] 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /backend/boards/migrations/0003_auto_20200923_1024_squashed_0005_auto_20200923_1030.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-23 10:30 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | replaces = [('boards', '0003_auto_20200923_1024'), ('boards', '0004_auto_20200923_1028'), ('boards', '0005_auto_20200923_1030')] 10 | 11 | dependencies = [ 12 | ('boards', '0002_auto_20200920_2158'), 13 | ('contenttypes', '0002_remove_content_type_name'), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name='board', 19 | name='owner_id', 20 | field=models.PositiveIntegerField(blank=True, null=True), 21 | ), 22 | migrations.AddField( 23 | model_name='board', 24 | name='owner_model', 25 | field=models.ForeignKey(limit_choices_to=models.Q(models.Q(('app_label', 'users'), ('model', 'user')), models.Q(('app_label', 'projects'), ('model', 'project')), _connector='OR'), on_delete=django.db.models.deletion.CASCADE, related_name='board', to='contenttypes.contenttype'), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/projects/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-20 14:30 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Project', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('title', models.CharField(max_length=255)), 23 | ('description', models.TextField(blank=True)), 24 | ('profile_picture', models.ImageField(blank=True, upload_to='project_profile_pics')), 25 | ('created_at', models.DateTimeField(default=django.utils.timezone.now)), 26 | ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_projects', to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /backend/projects/migrations/0002_projectmembership.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-20 15:05 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ('projects', '0001_initial'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='ProjectMembership', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('access_level', models.IntegerField(choices=[(1, 'Base'), (2, 'Intermediate'), (3, 'Owner')])), 22 | ('created_at', models.DateTimeField(default=django.utils.timezone.now)), 23 | ('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 24 | ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /backend/projects/urls.py: -------------------------------------------------------------------------------- 1 | """trello URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.urls import path 17 | from projects.views import ProjectList, ProjectDetail, ProjectMemberList, ProjectMemberDetail, SendProjectInvite, AcceptProjectInvite 18 | 19 | urlpatterns = [ 20 | path('', ProjectList.as_view()), 21 | path('/', ProjectDetail.as_view()), 22 | path('/members/', ProjectMemberList.as_view()), 23 | path('members//', ProjectMemberDetail.as_view()), 24 | path('/invite/', SendProjectInvite.as_view()), 25 | path('join//', AcceptProjectInvite.as_view()) 26 | ] 27 | -------------------------------------------------------------------------------- /frontend/src/components/headers/LandingHeader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from "react-router-dom"; 3 | 4 | import logo from '../../static/img/logo.png'; 5 | 6 | const LandingHeader = () => { 7 | return ( 8 |
9 |
10 | 11 |
12 | 13 |
14 | 31 |
32 | 33 |
34 |
    35 |
  • 36 | 37 | Login 38 | 39 |
  • 40 |
  • 41 | 42 | Sign Up 43 | 44 |
  • 45 |
46 |
47 |
48 | ); 49 | } 50 | 51 | export default LandingHeader; 52 | -------------------------------------------------------------------------------- /frontend/src/pages/Error404.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import useDocumentTitle from "../hooks/useDocumentTitle"; 4 | 5 | import globalContext from "../context/globalContext"; 6 | import bg from "../static/img/404.jpg"; 7 | 8 | const Error404 = () => { 9 | useDocumentTitle("Error | Trello"); 10 | const { authUser } = useContext(globalContext); 11 | 12 | return ( 13 |
14 | 15 |
16 |

17 | You look a little lost there 18 |

19 |

20 | {!authUser ? ( 21 | <> 22 | This page may be private. You may be able to view it 23 | by{" "} 24 | 25 | logging in 26 | 27 | 28 | ) : ( 29 | "This page cannot be found" 30 | )} 31 |

32 |
33 |
34 | ); 35 | }; 36 | 37 | export default Error404; 38 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_landing.scss: -------------------------------------------------------------------------------- 1 | .landing-header { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | z-index: 2; 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | color: white; 11 | background: transparent; 12 | 13 | a { 14 | color: white; 15 | } 16 | 17 | &__logo { 18 | object-fit:cover; 19 | height: 35px; 20 | width: 114px; 21 | } 22 | 23 | &__section { 24 | margin: 1em 0; 25 | 26 | &:first-of-type { 27 | margin-left: 2em; 28 | } 29 | 30 | &:last-of-type { 31 | margin-right: 2em; 32 | } 33 | } 34 | 35 | &__list { 36 | display: flex; 37 | align-items: center; 38 | } 39 | 40 | &__li { 41 | margin: 0 0.75em; 42 | 43 | &:first-of-type { 44 | margin-left: 0; 45 | } 46 | 47 | &:last-of-type { 48 | margin-right: 0; 49 | } 50 | } 51 | } 52 | 53 | .landing-banner { 54 | position: relative; // So we can postion children absolutely 55 | 56 | &__image { 57 | object-fit: cover; 58 | width: 100vw; 59 | height: 100vh; 60 | filter: brightness(80%); 61 | } 62 | 63 | &__content { 64 | color: white; 65 | position:absolute; 66 | top:50%; 67 | left:50%; 68 | transform:translate(-50%, -50%); 69 | text-align: center; 70 | } 71 | 72 | &__title { 73 | font-size: 3rem; 74 | font-weight: 700; 75 | margin-bottom: 0.5em; 76 | } 77 | 78 | &__subtitle { 79 | width: 70%; 80 | margin: 0 auto 2em; 81 | } 82 | } -------------------------------------------------------------------------------- /backend/boards/migrations/0006_auto_20201019_1644_squashed_0007_auto_20201019_1644.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-19 16:44 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | replaces = [('boards', '0006_auto_20201019_1644'), ('boards', '0007_auto_20201019_1644')] 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('boards', '0005_notification_squashed_0007_auto_20201014_0724'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name='notification', 20 | name='recipient', 21 | field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='users.user'), 22 | preserve_default=False, 23 | ), 24 | migrations.AlterField( 25 | model_name='board', 26 | name='description', 27 | field=models.TextField(), 28 | ), 29 | migrations.AlterField( 30 | model_name='board', 31 | name='owner_id', 32 | field=models.PositiveIntegerField(default=1), 33 | preserve_default=False, 34 | ), 35 | migrations.AlterField( 36 | model_name='board', 37 | name='description', 38 | field=models.TextField(blank=True), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /backend/boards/signals.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.db.models.signals import post_delete, post_save 3 | from django.dispatch import receiver 4 | 5 | from . import models 6 | 7 | 8 | label_colors = ["4680ff", "61bd4f", "ffab4a", "ff0000", 9 | "ffb64d", "c377e0", "ff80ce", "00c2e0", "51e898", "42548e"] 10 | 11 | 12 | @receiver(post_save, sender=models.Board) 13 | def create_board_labels(sender, instance, created, **kwargs): 14 | if created: 15 | for color in label_colors: 16 | models.Label.objects.create(board=instance, color=color) 17 | 18 | 19 | @receiver(post_save, sender=models.Comment) 20 | def create_comment_notification(sender, instance, created, **kwargs): 21 | if created: 22 | for user in instance.item.assigned_to.all(): 23 | if instance.author == user: # Don't create notification if you comment 24 | continue 25 | models.Notification.objects.create( 26 | actor=instance.author, recipient=user, 27 | verb='commented', action_object=instance, target=instance.item) 28 | 29 | 30 | @receiver(post_delete, sender=models.Comment) 31 | def delete_comment_notification(sender, instance, **kwargs): 32 | models.Notification.objects.filter( 33 | action_object_model=ContentType.objects.get(model='comment'), 34 | action_object_id=instance.id).delete() 35 | 36 | # Handle other notifications in views as we need to know request.user 37 | -------------------------------------------------------------------------------- /backend/projects/migrations/0004_auto_20200920_1853_squashed_0006_project_members.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-21 12:17 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | replaces = [('projects', '0004_auto_20200920_1853'), ('projects', '0005_auto_20200921_1210'), ('projects', '0006_project_members')] 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('projects', '0003_auto_20200920_1512'), 15 | ] 16 | 17 | operations = [ 18 | migrations.AlterField( 19 | model_name='projectmembership', 20 | name='member', 21 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL), 22 | ), 23 | migrations.AlterField( 24 | model_name='projectmembership', 25 | name='project', 26 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='projects.project'), 27 | ), 28 | migrations.AlterUniqueTogether( 29 | name='projectmembership', 30 | unique_together={('project', 'member')}, 31 | ), 32 | migrations.AddField( 33 | model_name='project', 34 | name='members', 35 | field=models.ManyToManyField(through='projects.ProjectMembership', to=settings.AUTH_USER_MODEL), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /frontend/src/components/modals/SearchModal.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import {modalBlurHandler} from '../../static/js/util'; 4 | import useAxiosGet from '../../hooks/useAxiosGet' 5 | 6 | import Card from '../boards/Card'; 7 | import SearchedBoard from '../boards/SearchedBoard'; 8 | 9 | const getSearchSuggestionsPosition = (searchElem) => { 10 | if (!searchElem) return null; 11 | return { 12 | top: searchElem.getBoundingClientRect().y + searchElem.getBoundingClientRect().height + 10 + "px", 13 | left : searchElem.getBoundingClientRect().x + "px" 14 | } 15 | } 16 | 17 | const SearchModal = ({backendQuery, searchElem, setShowModal}) => { 18 | const {data:cards} = useAxiosGet(`/boards/items/?q=${backendQuery}`); 19 | const {data:boards} = useAxiosGet(`/boards/?q=${backendQuery}`); 20 | 21 | useEffect(modalBlurHandler(setShowModal), []); 22 | 23 | return ( 24 |
25 |

Cards

26 |
    27 | {([]).map(card => ( 28 | 29 | ))} 30 |
31 | 32 |

Boards

33 |
    34 | {(boards || []).map(board => ( 35 | 36 | ))} 37 |
38 |
39 | ); 40 | } 41 | 42 | export default SearchModal; -------------------------------------------------------------------------------- /backend/projects/models.py: -------------------------------------------------------------------------------- 1 | from boards.models import Board 2 | from django.contrib.contenttypes.fields import GenericRelation 3 | from django.db import models 4 | from django.utils import timezone 5 | from users.models import User 6 | 7 | 8 | class Project(models.Model): 9 | owner = models.ForeignKey( 10 | User, on_delete=models.CASCADE, related_name='owned_projects') 11 | title = models.CharField(max_length=255, blank=False, null=False) 12 | description = models.TextField(blank=True, null=False) 13 | created_at = models.DateTimeField(default=timezone.now) 14 | members = models.ManyToManyField( 15 | User, through='ProjectMembership', through_fields=('project', 'member')) 16 | 17 | boards = GenericRelation( 18 | Board, object_id_field='owner_id', content_type_field='owner_model') 19 | 20 | def __str__(self): 21 | return self.title 22 | 23 | 24 | class ProjectMembership(models.Model): 25 | class Access(models.IntegerChoices): 26 | MEMBER = 1 # Can view and create and move only own items 27 | ADMIN = 2 # Can remove members and modify project settings. 28 | 29 | project = models.ForeignKey( 30 | Project, on_delete=models.CASCADE) 31 | member = models.ForeignKey( 32 | User, on_delete=models.CASCADE) 33 | access_level = models.IntegerField(choices=Access.choices, default=1) 34 | created_at = models.DateTimeField(default=timezone.now) 35 | 36 | def __str__(self): 37 | return f'{self.member.full_name} , {self.project.title}' 38 | 39 | class Meta: 40 | unique_together = ('project', 'member') 41 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_notification.scss: -------------------------------------------------------------------------------- 1 | .notification { 2 | margin-bottom: 0.5em; 3 | font-weight: 400; // To override label-modal font-weight 4 | border-bottom: 1px solid $border-color; 5 | padding: 1em; 6 | padding-bottom: 1.75em; 7 | 8 | &:last-of-type { 9 | border: 0; 10 | padding-bottom: 1em; 11 | } 12 | 13 | &__card { 14 | border: 1px solid $border-color; 15 | border-radius: 5px; 16 | padding: 0.5em; 17 | margin-bottom: 0.875em; 18 | } 19 | 20 | &__header { 21 | display: flex; 22 | align-items: center; 23 | } 24 | 25 | &__name { 26 | font-weight: 500; 27 | margin-left: 0.5em; 28 | } 29 | 30 | &__subtitle { 31 | font-size: 0.875rem; 32 | font-weight: 300; 33 | color: $secondary-text; 34 | margin-left: 0.5em; 35 | } 36 | 37 | &__text { 38 | margin-top: 0.875em; 39 | line-height: 1.2; 40 | i { 41 | margin-right: 0.5em; 42 | } 43 | } 44 | 45 | &__none { 46 | height: 100px; 47 | } 48 | } 49 | 50 | .label-modal__filter { 51 | display: flex; 52 | justify-content: space-between; 53 | font-weight: 400; 54 | font-size: 0.875rem; 55 | color: $blue; 56 | margin-bottom: 0.875em; 57 | 58 | button { 59 | width: fit-content !important; 60 | } 61 | } 62 | 63 | .label-modal__no-notif { 64 | height: 250px; 65 | display: flex; 66 | flex-direction: column; 67 | justify-content: center; 68 | align-items: center; 69 | 70 | img { 71 | margin-bottom: 2em; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /backend/projects/serializers.py: -------------------------------------------------------------------------------- 1 | from projects.models import Project, ProjectMembership 2 | from users.models import User 3 | from users.serializers import UserSerializer 4 | from rest_framework import serializers 5 | 6 | 7 | class ProjectMembershipSerializer(serializers.ModelSerializer): 8 | full_name = serializers.CharField( 9 | source='member.full_name', read_only=True) 10 | username = serializers.CharField(source='member.username', read_only=True) 11 | email = serializers.CharField(source='member.email', read_only=True) 12 | profile_pic = serializers.SerializerMethodField() 13 | 14 | def get_profile_pic(self, obj): 15 | if obj.member.profile_pic: 16 | return self.context['request'].build_absolute_uri(obj.member.profile_pic.url) 17 | return None 18 | 19 | class Meta: 20 | model = ProjectMembership 21 | fields = ['id', 'full_name', 'username', 22 | 'email', 'profile_pic', 'access_level'] 23 | 24 | 25 | class ProjectSerializer(serializers.ModelSerializer): 26 | owner = UserSerializer(read_only=True) 27 | members = serializers.SerializerMethodField() 28 | 29 | def get_members(self, obj): 30 | queryset = ProjectMembership.objects.filter(project=obj) 31 | return ProjectMembershipSerializer(queryset, many=True, context={"request": self.context['request']}).data 32 | 33 | class Meta: 34 | model = Project 35 | fields = [ 36 | 'id', 37 | 'owner', 38 | 'title', 39 | 'description', 40 | 'members' 41 | ] 42 | read_only_fields = ['owner'] 43 | 44 | 45 | class ShortProjectSerializer(serializers.ModelSerializer): 46 | class Meta: 47 | model = Project 48 | fields = ['id', 'title'] 49 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 16 | 17 | 26 | React App 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /frontend/src/components/auth/LoginForm.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import axios from 'axios'; 3 | 4 | import globalContext from '../../context/globalContext' 5 | import { backendUrl } from '../../static/js/const'; 6 | import { useForm } from "react-hook-form"; 7 | 8 | const LoginForm = ({setErrMsgs}) => { 9 | const { register, handleSubmit, watch } = useForm(); 10 | 11 | const userName = watch("username", ""); 12 | const userPassword = watch("password", ""); 13 | const { login } = useContext(globalContext); 14 | 15 | const onSubmit = async (data) => { 16 | const url = `${backendUrl}/token/`; 17 | try { 18 | const res = await axios.post(url, data); 19 | login(res.data); 20 | } 21 | catch (err) { 22 | if (err.response?.status === 401){ 23 | setErrMsgs({signup: false, err: true, msgs: { Invalid: 'username or password'}}); 24 | } 25 | } 26 | } 27 | 28 | return ( 29 |
30 |
31 |
32 | 39 | 46 | { 47 | userName.trim() !== '' && userPassword.trim() !== '' ? 48 | : 49 | 50 | } 51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | export default LoginForm; -------------------------------------------------------------------------------- /frontend/src/components/sidebars/HomeSidebar.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | 5 | const HomeSidebar = ({ setShowTeamModal, projects }) => { 6 | return ( 7 |
8 | 25 | 26 | 34 |
    35 |
  • 36 | {projects.map((project) => ( 37 | 42 | {project.title} 43 | 44 | ))} 45 |
  • 46 |
47 |
48 | ); 49 | }; 50 | 51 | export default HomeSidebar; 52 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_search.scss: -------------------------------------------------------------------------------- 1 | .search-suggestions { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | width: 650px; 6 | z-index: 4; 7 | border-radius: 5px; 8 | background: white; 9 | padding: 2em; 10 | 11 | &__title { 12 | font-weight: 500; 13 | font-size: 1.25rem; 14 | margin-bottom: 0.8em; 15 | } 16 | 17 | &__cards { 18 | margin-bottom: 2em; 19 | 20 | > li { 21 | margin-bottom: 1em; 22 | } 23 | 24 | .card { 25 | display: flex; 26 | width: 100%; 27 | padding-bottom: 0; 28 | 29 | &__image { 30 | flex: 3; 31 | margin: 0; 32 | margin-left: -0.75em; 33 | margin-top: -0.75em; 34 | 35 | img { 36 | border-radius: 5px 0 0 5px; 37 | width: 100%; 38 | height: 100%; 39 | object-fit: cover; 40 | max-height: 125px; 41 | } 42 | } 43 | 44 | > div:last-of-type { 45 | flex: 4; 46 | border: 0; 47 | margin-left: 1em; 48 | 49 | .card__members { 50 | position: absolute; 51 | margin-top: 0; 52 | bottom: 0.75em; 53 | right: 0.75em; 54 | } 55 | } 56 | } 57 | } 58 | 59 | &__boards { 60 | display: flex; 61 | justify-content: space-between; 62 | 63 | li { 64 | width: 48%; 65 | } 66 | } 67 | } 68 | 69 | .searched-board { 70 | border: 1px rgba($border-color, 0.55) solid; 71 | border-radius: 5px; 72 | padding: 0.75em; 73 | background: white; 74 | width: 47%; 75 | color: black; 76 | 77 | &:hover { 78 | background: darken(white, 2%); 79 | } 80 | 81 | &__subtitle { 82 | margin-top: 0.75em; 83 | font-weight: 300; 84 | font-size: 0.875rem; 85 | color: $secondary-text; 86 | display: flex; 87 | 88 | p { 89 | padding: 0 0.75em; 90 | &:first-of-type { 91 | padding-left: 0; 92 | border-right: 1px $border-color solid; 93 | } 94 | } 95 | } 96 | 97 | &--red { 98 | border-top: 3px $red solid; 99 | } 100 | 101 | &--blue { 102 | border-top: 3px $blue solid; 103 | } 104 | 105 | &--yellow { 106 | border-top: 3px $yellow solid; 107 | } 108 | } -------------------------------------------------------------------------------- /frontend/src/pages/Login.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import LoginForm from "../components/auth/LoginForm"; 3 | import RegisterForm from "../components/auth/RegisterForm"; 4 | import AuthError from "../components/modals/AuthError"; 5 | import logo from "../static/img/logo2.png"; 6 | import bg from "../static/img/slideshow-1.jpg"; 7 | import useDocumentTitle from "../hooks/useDocumentTitle"; 8 | 9 | const Login = ({ login }) => { 10 | useDocumentTitle(login ? "Login | Trello" : "Sign Up | Trello"); 11 | const [errmsgs, setErrMsgs] = useState({ msgs: {}, err: false }); 12 | return ( 13 | <> 14 |
15 | {errmsgs.err ? ( 16 | 22 | ) : null} 23 |
24 |
25 | trello 30 |
31 | {login ? ( 32 | 33 | ) : ( 34 | 35 | )} 36 |
37 |
38 |
39 | 40 |
41 |
42 |

Plan your tasks

43 |

Plan out your tasks and vacations using trello

44 |
45 |
46 | 47 | ); 48 | }; 49 | 50 | Login.defaultProps = { 51 | login: true, 52 | }; 53 | 54 | export default Login; 55 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAxiosGet.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { authAxios } from "../static/js/util"; 3 | import { backendUrl } from "../static/js/const"; 4 | import axios from "axios"; 5 | 6 | const useAxiosGet = (url, onSite = true) => { 7 | const [data, setData] = useState(null); 8 | const [error, setError] = useState(false); 9 | const [errorMessage, setErrorMessage] = useState(null); 10 | const [loading, setLoading] = useState(true); 11 | 12 | useEffect(() => { 13 | let unmounted = false; 14 | const fetchData = async () => { 15 | const axiosUsed = onSite ? authAxios : axios; 16 | url = onSite ? backendUrl + url : url; 17 | 18 | try { 19 | const res = await axiosUsed.get(url); 20 | if (!unmounted) { 21 | setData(res.data); 22 | setLoading(false); 23 | } 24 | } catch (error) { 25 | if (!unmounted) { 26 | setError(true); 27 | setErrorMessage(error.message); 28 | setLoading(false); 29 | } 30 | } 31 | }; 32 | fetchData(); 33 | return () => { 34 | unmounted = true; 35 | }; 36 | }, [url]); 37 | 38 | // Below three functions are only if data is an array 39 | const addItem = (item) => { 40 | setData((prevData) => [...prevData, item]); 41 | }; 42 | 43 | const replaceItem = (newItem, key = "id") => { 44 | setData((prevData) => 45 | prevData.map((item) => { 46 | if (item[key] === newItem[key]) return newItem; 47 | return item; 48 | }) 49 | ); 50 | }; 51 | 52 | const removeItem = (id) => { 53 | setData((prevData) => prevData.filter((item) => item.id !== id)); 54 | }; 55 | 56 | return { 57 | data, 58 | setData, 59 | loading, 60 | error, 61 | errorMessage, 62 | addItem, 63 | replaceItem, 64 | removeItem, 65 | }; 66 | }; 67 | 68 | export default useAxiosGet; 69 | -------------------------------------------------------------------------------- /frontend/src/context/GlobalProvider.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | 4 | import { backendUrl } from "../static/js/const"; 5 | import { authAxios } from "../static/js/util"; 6 | import globalContext from "./globalContext"; 7 | import { 8 | globalReducer, 9 | LOGIN, 10 | LOGOUT, 11 | SET_BOARD_CONTEXT, 12 | } from "./globalReducer"; 13 | 14 | const GlobalProvider = (props) => { 15 | const history = useHistory(); 16 | const [globalState, dispatch] = useReducer(globalReducer, { 17 | authUser: null, 18 | checkedAuth: false, 19 | board: null, 20 | setBoard: null, 21 | }); 22 | 23 | const login = async (resData) => { 24 | localStorage.setItem("accessToken", resData.access); 25 | localStorage.setItem("refreshToken", resData.refresh); 26 | const url = backendUrl + "/me/"; 27 | 28 | // No try catch block so error bubbles up to LoginForm.js to be handled there 29 | const { data: user } = await authAxios.get(url); 30 | dispatch({ type: LOGIN, user }); 31 | history.push("/"); 32 | }; 33 | 34 | const checkAuth = async () => { 35 | const url = backendUrl + "/me/"; 36 | try { 37 | const { data: user } = await authAxios.get(url); 38 | dispatch({ type: LOGIN, user }); 39 | } catch (err) { 40 | dispatch({ type: LOGOUT }); 41 | } 42 | }; 43 | 44 | const logout = () => { 45 | dispatch({ type: LOGOUT }); 46 | history.push("/login"); 47 | }; 48 | 49 | const setBoardContext = (board, setBoard) => { 50 | dispatch({ type: SET_BOARD_CONTEXT, board, setBoard }); 51 | }; 52 | 53 | return ( 54 | 66 | {props.children} 67 | 68 | ); 69 | }; 70 | 71 | export default GlobalProvider; 72 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_addboard-modal.scss: -------------------------------------------------------------------------------- 1 | .addboard-modal { 2 | top: 0; 3 | right: 50%; 4 | transform: translateX(50%); 5 | display: flex; 6 | position: fixed; 7 | z-index: 4; 8 | 9 | &__title-block { 10 | height: 160px; 11 | background-repeat: no-repeat; 12 | background-size: cover; 13 | //background-image: ; 14 | border-radius: 5px; 15 | width: 350px; 16 | } 17 | 18 | &__title { 19 | border: 0; 20 | font-weight: 600; 21 | color: white; 22 | margin-left: 0.5em; 23 | margin-top: 0.5em; 24 | background-color: transparent; 25 | border-radius: 5px; 26 | padding: 0.5em; 27 | font-size: 1.125rem; 28 | &:hover, 29 | &:focus { 30 | background-color: rgba(white, 0.2); 31 | } 32 | 33 | &::placeholder { 34 | color: #bcbcbc; 35 | } 36 | } 37 | 38 | &__exit { 39 | float: right; 40 | margin-right: 1em; 41 | margin-top: 1em; 42 | font-size: 1.125rem; 43 | color: white; 44 | } 45 | 46 | &__create { 47 | margin-top: 0.5em; 48 | } 49 | 50 | &__right { 51 | margin-left: 0.5em; 52 | width: 200px; 53 | } 54 | 55 | &__color-box { 56 | height: 48px; 57 | width: 48px; 58 | border: 0; 59 | border-radius: 5px; 60 | margin-bottom: 0.5em; 61 | margin-right: 0.5em; 62 | position: relative; 63 | background-color: white; 64 | 65 | > i { 66 | position: absolute; 67 | top: 50%; 68 | left: 50%; 69 | transform: translate(-50%, -50%); 70 | color: white; 71 | font-size: 1.6rem; 72 | } 73 | 74 | &:hover { 75 | filter: brightness(90%); 76 | } 77 | 78 | &--img { 79 | //background-image: url("img/bg6.jpg"); 80 | background-repeat: no-repeat; 81 | background-size: cover; 82 | } 83 | 84 | &--tick { 85 | filter: brightness(70%); 86 | } 87 | 88 | .fa-ellipsis-h { 89 | color: $secondary-text; 90 | } 91 | } 92 | 93 | .btn--disabled { 94 | background-color: rgba($border-color, 1); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /backend/users/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core.exceptions import ValidationError 3 | from django.test import Client 4 | from mixer.backend.django import mixer 5 | 6 | from ..models import User 7 | 8 | pytestmark = pytest.mark.django_db 9 | 10 | class TestUserModel: 11 | def test_model(self): 12 | user = mixer.blend(User) 13 | assert user.pk == 1 14 | 15 | def test_username_case_insensitive(self): 16 | user = mixer.blend(User, username='john') 17 | assert User.objects.get(username='JOhn') == user 18 | 19 | def test_username_case_insensitive2(self): 20 | user = mixer.blend(User, username='john') 21 | assert User.objects.filter(username='JOhn').count() == 1 22 | 23 | def test_email_case_insensitive(self): 24 | user = mixer.blend(User, email='johndoe@gmail.com') 25 | assert User.objects.get(email='JoHnDoe@Gmail.com') == user 26 | 27 | def test_email_case_insensitive2(self): 28 | user = mixer.blend(User, email='johndoe@gmail.com') 29 | assert User.objects.filter(email='JoHnDoe@Gmail.com').count() == 1 30 | 31 | def test_full_name(self): 32 | user = mixer.blend(User, first_name='John', last_name='Doe') 33 | assert user.full_name == 'John Doe' 34 | 35 | def test_username_regex(self): 36 | user = User( 37 | username = 'johndoe@gmail.com', 38 | email = 'johndoe@gmail.com', 39 | first_name = 'John', 40 | last_name = 'Doe', 41 | ) 42 | user.set_password('}P-9(e,W') 43 | with pytest.raises(ValidationError): 44 | if user.full_clean(): 45 | user.save() 46 | assert User.objects.filter(username='johndoe@gmail.com').count() == 0 47 | 48 | class TestUserLogin: 49 | def test_username_case_insensitive(self): 50 | c = Client() 51 | user = mixer.blend(User, username='john') 52 | user.set_password('}P-9(e,W') 53 | user.save() 54 | assert c.login(username='JohN', password='}P-9(e,W') == True 55 | 56 | def test_user_can_login_with_email(self): 57 | c = Client() 58 | user = mixer.blend(User, username='john', email='johndoe@gmail.com') 59 | user.set_password('}P-9(e,W') 60 | user.save() 61 | assert c.login(username='johndoe@gmail.com', password='}P-9(e,W') == True 62 | -------------------------------------------------------------------------------- /backend/boards/migrations/0005_notification_squashed_0007_auto_20201014_0724.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-10-14 07:28 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | replaces = [('boards', '0005_notification'), ('boards', '0006_auto_20201014_0723'), ('boards', '0007_auto_20201014_0724')] 12 | 13 | dependencies = [ 14 | ('boards', '0004_auto_20200924_0558'), 15 | ('contenttypes', '0002_remove_content_type_name'), 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Notification', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('verb', models.CharField(max_length=255)), 25 | ('unread', models.BooleanField(db_index=True, default=True)), 26 | ('created_at', models.DateTimeField(default=django.utils.timezone.now)), 27 | ('target_id', models.PositiveIntegerField(blank=True, null=True)), 28 | ('action_object_id', models.PositiveIntegerField(blank=True, null=True)), 29 | ('action_object_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='action_object_obj', to='contenttypes.contenttype')), 30 | ('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to=settings.AUTH_USER_MODEL)), 31 | ('target_model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='target_obj', to='contenttypes.contenttype')), 32 | ], 33 | ), 34 | migrations.RenameField( 35 | model_name='comment', 36 | old_name='user', 37 | new_name='author', 38 | ), 39 | migrations.RemoveField( 40 | model_name='comment', 41 | name='description', 42 | ), 43 | migrations.AddField( 44 | model_name='comment', 45 | name='body', 46 | field=models.TextField(default=''), 47 | preserve_default=False, 48 | ), 49 | ] 50 | -------------------------------------------------------------------------------- /frontend/src/static/css/base/_reset.scss: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, 6 | *:before, 7 | *:after { 8 | box-sizing: inherit; 9 | } 10 | 11 | html, 12 | body, 13 | div, 14 | span, 15 | applet, 16 | object, 17 | iframe, 18 | h1, 19 | h2, 20 | h3, 21 | h4, 22 | h5, 23 | h6, 24 | p, 25 | blockquote, 26 | pre, 27 | a, 28 | abbr, 29 | acronym, 30 | address, 31 | big, 32 | cite, 33 | code, 34 | del, 35 | dfn, 36 | em, 37 | img, 38 | ins, 39 | kbd, 40 | q, 41 | s, 42 | samp, 43 | small, 44 | strike, 45 | strong, 46 | sub, 47 | sup, 48 | tt, 49 | var, 50 | b, 51 | u, 52 | i, 53 | center, 54 | dl, 55 | dt, 56 | dd, 57 | ol, 58 | ul, 59 | li, 60 | fieldset, 61 | form, 62 | label, 63 | legend, 64 | table, 65 | caption, 66 | tbody, 67 | tfoot, 68 | thead, 69 | tr, 70 | th, 71 | td, 72 | article, 73 | aside, 74 | canvas, 75 | details, 76 | embed, 77 | figure, 78 | figcaption, 79 | footer, 80 | header, 81 | hgroup, 82 | menu, 83 | nav, 84 | output, 85 | ruby, 86 | section, 87 | summary, 88 | time, 89 | mark, 90 | audio, 91 | video { 92 | margin: 0; 93 | padding: 0; 94 | border: 0; 95 | font-size: 100%; 96 | font: inherit; 97 | vertical-align: baseline; 98 | } 99 | /* HTML5 display-role reset for older browsers */ 100 | article, 101 | aside, 102 | details, 103 | figcaption, 104 | figure, 105 | footer, 106 | header, 107 | hgroup, 108 | menu, 109 | nav, 110 | section { 111 | display: block; 112 | } 113 | body { 114 | line-height: 1; 115 | } 116 | ol, 117 | ul { 118 | list-style: none; 119 | } 120 | blockquote, 121 | q { 122 | quotes: none; 123 | } 124 | blockquote:before, 125 | blockquote:after, 126 | q:before, 127 | q:after { 128 | content: ""; 129 | content: none; 130 | } 131 | table { 132 | border-collapse: collapse; 133 | border-spacing: 0; 134 | } 135 | 136 | img { 137 | display: block; 138 | } 139 | 140 | a { 141 | text-decoration: none; 142 | cursor: pointer; 143 | } 144 | 145 | input:focus, 146 | select:focus, 147 | textarea:focus, 148 | button:focus { 149 | outline: none; 150 | } 151 | 152 | button, 153 | input[type="submit"], 154 | input[type="reset"] { 155 | background: none; 156 | color: inherit; 157 | border: none; 158 | padding: 0; 159 | font: inherit; 160 | cursor: pointer; 161 | outline: inherit; 162 | } 163 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useContext } from "react"; 2 | import { Route, Switch } from "react-router-dom"; 3 | 4 | import globalContext from "./context/globalContext"; 5 | import Header from "./components/headers/Header"; 6 | import LandingHeader from "./components/headers/LandingHeader"; 7 | import AddBoardModal from "./components/modals/AddBoardModal"; 8 | import Landing from "./pages/Landing"; 9 | import Login from "./pages/Login"; 10 | import Home from "./pages/Home"; 11 | import Board from "./pages/Board"; 12 | import Project from "./pages/Project"; 13 | import Error404 from "./pages/Error404"; 14 | 15 | function App() { 16 | const { checkAuth, checkedAuth, authUser } = useContext(globalContext); 17 | 18 | useEffect(() => { 19 | checkAuth(); 20 | }, []); 21 | 22 | if (!checkedAuth) { 23 | return null; 24 | } 25 | 26 | if (authUser) 27 | return ( 28 | <> 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | else 40 | return ( 41 | <> 42 | { 45 | const { pathname } = props.location; 46 | if (pathname === "/login" || pathname === "/register") 47 | return null; 48 | return ; 49 | }} 50 | /> 51 | 52 | 53 | 54 | } 58 | /> 59 | 60 | 61 | 62 | ); 63 | } 64 | 65 | export default App; 66 | -------------------------------------------------------------------------------- /backend/trello/urls.py: -------------------------------------------------------------------------------- 1 | """trello URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.1/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.urls import include, path, re_path 20 | from rest_framework import permissions 21 | from rest_framework_simplejwt import views as jwt_views 22 | from users.views import Me, RegisterUser 23 | from boards.views import NotificationList 24 | 25 | #from drf_yasg.views import get_schema_view 26 | #from drf_yasg import openapi 27 | 28 | """ 29 | schema_view = get_schema_view( 30 | openapi.Info( 31 | title="Snippets API", 32 | default_version='v1', 33 | description="Test description", 34 | terms_of_service="https://www.google.com/policies/terms/", 35 | contact=openapi.Contact(email="contact@snippets.local"), 36 | license=openapi.License(name="BSD License"), 37 | ), 38 | public=True, 39 | permission_classes=(permissions.AllowAny,), 40 | ) 41 | """ 42 | urlpatterns = [ 43 | path('admin/', admin.site.urls), 44 | path('register/', RegisterUser.as_view(), name='register'), 45 | path('token/', jwt_views.TokenObtainPairView.as_view(), 46 | name='token_obtain_pair'), 47 | path('token/refresh/', jwt_views.TokenRefreshView.as_view(), name='token_refresh'), 48 | path('projects/', include('projects.urls')), 49 | path('boards/', include('boards.urls')), 50 | path('me/', Me.as_view(), name="me"), 51 | path('notifications/', NotificationList.as_view(), name="notification-list"), 52 | # re_path(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), 53 | # re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), 54 | # re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), 55 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 56 | -------------------------------------------------------------------------------- /frontend/src/components/boards/HomeBoard.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ProfilePic from "./ProfilePic"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | import { Link } from "react-router-dom"; 5 | 6 | import { authAxios } from "../../static/js/util"; 7 | import { backendUrl } from "../../static/js/const"; 8 | 9 | const HomeBoard = ({ board, replaceBoard }) => { 10 | const toggleFavorite = async (e) => { 11 | e.preventDefault(); // Prevent anchor link wrapped around board from redirecting us 12 | await authAxios.post(`${backendUrl}/boards/star/`, { 13 | board: board.id, 14 | }); 15 | replaceBoard({ 16 | ...board, 17 | is_starred: !board.is_starred, 18 | }); 19 | }; 20 | 21 | return ( 22 | 23 | 35 | {board.color ? ( 36 |
40 | ) : ( 41 |
42 | 43 |
44 | )} 45 |

49 | {board.title} 50 |

51 | {board.members && } 52 | 53 | ); 54 | }; 55 | 56 | const Members = ({ members }) => { 57 | return ( 58 |
59 | {members.slice(0, 3).map((member) => ( 60 | 61 | ))} 62 | {members.length > 3 && ( 63 |

{`+${members.length - 3} other${ 64 | members.length - 3 === 1 ? "" : "s" 65 | }`}

66 | )} 67 |
68 | ); 69 | }; 70 | 71 | export default HomeBoard; 72 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_createteam.scss: -------------------------------------------------------------------------------- 1 | .create-team { 2 | z-index: 4; 3 | background-color: white; 4 | border-radius: 5px; 5 | position: fixed; 6 | top: 50%; 7 | left: 50%; 8 | transform: translate(-50%, -50%); 9 | width: 925px; 10 | 11 | display: flex; 12 | 13 | &__form { 14 | flex: 1; 15 | padding: 3em 0 3em 3em; 16 | 17 | label { 18 | display: block; 19 | font-size: 0.75rem; 20 | font-weight: 500; 21 | margin-bottom: 0.3em; 22 | } 23 | 24 | input { 25 | padding: 0.5em; 26 | width: 80%; 27 | margin-bottom: 1.4em; 28 | border: 1px darken($border-color, 40%) solid; 29 | 30 | &:focus { 31 | border: 1px $blue solid; 32 | } 33 | } 34 | 35 | textarea { 36 | padding: 0.5em; 37 | width: 80%; 38 | height: 100px; 39 | resize: none; 40 | border: 1px darken($border-color, 40%) solid; 41 | margin-bottom: 1.4em; 42 | 43 | &:focus { 44 | border: 1px $blue solid; 45 | } 46 | } 47 | 48 | .btn { 49 | display: block; 50 | width: 80%; 51 | text-align: center; 52 | } 53 | } 54 | 55 | &__title { 56 | font-weight: 600; 57 | font-size: 1.5rem; 58 | margin-bottom: 0.75em; 59 | } 60 | 61 | &__subtitle { 62 | color: $secondary-text; 63 | margin-bottom: 1.5em; 64 | } 65 | 66 | &__bg { 67 | flex: 1; 68 | background-image: url("../img/teambg.svg"); 69 | background-repeat: no-repeat; 70 | background-size: 100% 100%; 71 | background-position: right; 72 | 73 | position: relative; 74 | overflow: hidden; 75 | 76 | .fal { 77 | position: absolute; 78 | top: 20px; 79 | right: 20px; 80 | font-size: 1.25rem; 81 | } 82 | } 83 | 84 | &__img { 85 | margin: 0 auto; 86 | margin-top: 70px; 87 | } 88 | 89 | &__face { 90 | position: absolute; 91 | transition: all 100ms ease; 92 | height: 70px; 93 | width: 70px; 94 | 95 | &--1 { 96 | left: -100px; 97 | top: 460px; 98 | //Animate to 99 | //left: 60px; 100 | //top: 230px; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /backend/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-20 12:50 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | import users.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ('auth', '0012_alter_user_first_name_max_length'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='User', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('password', models.CharField(max_length=128, verbose_name='password')), 22 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 23 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 24 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 25 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 26 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 27 | ('username', models.CharField(max_length=15, unique=True)), 28 | ('email', models.EmailField(max_length=255, unique=True)), 29 | ('first_name', models.CharField(max_length=255)), 30 | ('last_name', models.CharField(max_length=255)), 31 | ('profile_pic', models.ImageField(blank=True, upload_to='profile_pics')), 32 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 33 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 34 | ], 35 | options={ 36 | 'verbose_name': 'user', 37 | 'verbose_name_plural': 'users', 38 | 'abstract': False, 39 | }, 40 | managers=[ 41 | ('objects', users.models.CustomUserManager()), 42 | ], 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_login.scss: -------------------------------------------------------------------------------- 1 | .slideshow { 2 | position: absolute; 3 | z-index: 1; 4 | position: fixed; 5 | left: 500px; 6 | height: 100%; 7 | width: 100%; 8 | 9 | &__img { 10 | object-fit: contain; 11 | height: auto; 12 | max-width: 100%; 13 | } 14 | } 15 | 16 | .splash-text { 17 | font-size: large; 18 | position: absolute; 19 | z-index: 2; 20 | color: white; 21 | bottom: 100px; 22 | left: 100px; 23 | 24 | &__h1 { 25 | font-size: 3.4rem; 26 | font-weight: 600; 27 | line-height: 5rem; 28 | } 29 | } 30 | 31 | .sidebar{ 32 | position: fixed; 33 | width: 500px; 34 | height: 100%; 35 | overflow-x: hidden; 36 | overflow-y: auto; 37 | background: white; 38 | display: flex; 39 | flex-direction: column; 40 | 41 | &--left { 42 | left: 0; 43 | } 44 | 45 | &--right { 46 | right: 0; 47 | } 48 | 49 | &-logo { 50 | &__img{ 51 | width: 7em; 52 | position: relative; 53 | margin: 12% 12%; 54 | left: 2em; 55 | } 56 | } 57 | } 58 | 59 | .sidebar-input { 60 | padding: 0.5em; 61 | margin: 0.5em auto; 62 | height: 3em; 63 | width: 80%; 64 | z-index: 1; 65 | font-size: 1rem; 66 | } 67 | 68 | .login { 69 | position: relative; 70 | width: 100%; 71 | margin: 8% auto; 72 | 73 | &-fieldset { 74 | display: flex; 75 | flex-direction: column; 76 | width: 81%; 77 | margin: auto; 78 | text-align: center; 79 | 80 | &__submit:hover{ 81 | transform: scale(0.9); 82 | cursor: pointer; 83 | } 84 | 85 | &__submit { 86 | text-align: center; 87 | } 88 | 89 | > .btn { 90 | margin-top: 1.5em; 91 | margin-left: auto; 92 | margin-right: 10%; 93 | width: fit-content; 94 | } 95 | } 96 | } 97 | 98 | .border { 99 | &--gray{ 100 | border: rgba($border-color, 0.7); 101 | border-style: solid; 102 | border-width: 1px; 103 | border-radius: 5px; 104 | } 105 | 106 | &--onHoverBlue:hover{ 107 | border: $blue; 108 | border-style: solid; 109 | border-width: 1px; 110 | } 111 | } 112 | 113 | .buttons { 114 | display: flex; 115 | flex-direction: row; 116 | justify-items: space-evenly; 117 | margin-left: 10%; 118 | margin-right: 10%; 119 | margin-top: 15px; 120 | > .btn { 121 | flex: 1; 122 | } 123 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # trello-clone 2 | A Trello clone made using Django Rest Framework, SASS, React, and Redis. 3 | 4 | ## Features 5 | - Register and Login 6 | - JWT Authentication to connect DRF and React 7 | - Can login with username or email 8 | - Projects (Teams) 9 | - Create Projects 10 | - Invite members to join projects via one time link 11 | - Change member access level - Admin or Normal 12 | - Admin can edit project details, invite new members, and change other members' access levels. 13 | - Boards 14 | - Create personal boards or project boards 15 | - Recently Viewed Boards 16 | - Starred Boards 17 | - Create and reorder lists 18 | - Create, reorder, and change list of cards 19 | - Add labels to cards 20 | - Assign members to cards 21 | - Add attachments to cards 22 | - Add comments to cards 23 | - Search 24 | - Autocomplete (Debounced) 25 | - Unsplash API Integration 26 | - Set environment variable REACT_APP_UNSPLASH_API_ACCESS_KEY with access key 27 | - Automatically adapt header and board title styling based on brightness of board background 28 | - Notifications 29 | - When someone assigns you to a card 30 | - When someone comments on a card you're assigned to 31 | - When you're invited to a project 32 | - When someone makes you admin of a project 33 | 34 | ## Getting Started 35 | 1. Install [Python](https://www.python.org/downloads/), [Yarn](https://classic.yarnpkg.com/en/docs/install/), [Redis](https://redis.io/download). 36 | 2. Clone the repo 37 | ``` 38 | $ git clone https://github.com/vdevired/trello-clone.git 39 | $ cd trello-clone 40 | ``` 41 | 3. Install [pipenv](https://pypi.org/project/pipenv/), a python virtual environment manager. Install backend dependencies and run migrations to create database. Default database is SQLite. 42 | ``` 43 | $ cd backend 44 | $ pipenv install 45 | $ pipenv run python manage.py migrate 46 | ``` 47 | 4. Install frontend dependencies. 48 | ``` 49 | $ cd frontend 50 | $ yarn install 51 | ``` 52 | 5. Run redis on port 6380 53 | ``` 54 | $ redis-server --port 6380 55 | ``` 56 | 6. Run both frontend and backend servers with following commands in appropriate directories. 57 | ``` 58 | $ pipenv run python manage.py runserver 59 | $ yarn start 60 | ``` 61 | 62 | ## Screenshots 63 | ![Board Page Video](https://i.imgur.com/gDTkwAS.gif) 64 | ![Landing Page](https://imgur.com/CTgpNlD.jpg) 65 | ![Login Page](https://imgur.com/as4jhYS.jpg) 66 | ![Home Page](https://imgur.com/FV0UirA.jpg) 67 | ![Add Board Modal](https://imgur.com/tO5fRW8.png) 68 | ![Create Project Modal](https://imgur.com/CXeD3C2.jpg) 69 | ![Project Page](https://imgur.com/QOsbu3y.jpg) 70 | ![Project Page 2](https://i.imgur.com/PGbDYvS.png) 71 | ![Card Modal](https://i.imgur.com/xpFOTsO.png) 72 | 73 | Design Inspiration: https://www.behance.net/gallery/47031411/Trello-Atlassian-Redesign -------------------------------------------------------------------------------- /frontend/src/components/modals/InviteMembersModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { backendUrl } from "../../static/js/const"; 3 | import { authAxios } from "../../static/js/util"; 4 | 5 | const getInviteMembersPosition = () => { 6 | const inviteElem = document.querySelector(".team__members-header button"); 7 | if (!inviteElem) return null; 8 | return { 9 | top: 10 | inviteElem.getBoundingClientRect().y + 11 | inviteElem.getBoundingClientRect().height + 12 | 10 + 13 | "px", 14 | left: inviteElem.getBoundingClientRect().x + "px", 15 | }; 16 | }; 17 | 18 | const InviteMembersModal = ({ project, setShowModal }) => { 19 | const [members, setMembers] = useState(""); 20 | 21 | const handleInvite = async () => { 22 | const invitedMembers = 23 | members !== "" 24 | ? members.split(",").map((user) => user.trim()) // usernames and emails 25 | : []; 26 | 27 | try { 28 | await authAxios.post( 29 | backendUrl + `/projects/${project.id}/invite/`, 30 | { 31 | users: invitedMembers, 32 | } 33 | ); 34 | } catch (error) { 35 | console.log(error); 36 | } 37 | setShowModal(false); 38 | }; 39 | 40 | return ( 41 |
45 |
46 |

Add Members

47 | 50 |
51 |
52 |

53 | 54 | Enter Email Address or Username 55 |

56 | setMembers(e.target.value)} 63 | /> 64 | {members.trim() !== "" ? ( 65 | 68 | ) : ( 69 | 72 | )} 73 |
74 |
75 | ); 76 | }; 77 | 78 | export default InviteMembersModal; 79 | -------------------------------------------------------------------------------- /backend/boards/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from ..models import Label 2 | import pytest 3 | from mixer.backend.django import mixer 4 | pytestmark = pytest.mark.django_db 5 | 6 | 7 | class TestBoard: 8 | def test_model(self): 9 | board = mixer.blend('boards.Board') 10 | assert board.pk == 1, 'Should create a Board instance' 11 | 12 | def test_str(self): 13 | board = mixer.blend('boards.Board') 14 | assert board.title == str(board), 'Should check the board name' 15 | 16 | def test_labels_signal(self): 17 | board = mixer.blend('boards.Board') 18 | assert Label.objects.filter(board=board).count() == 10 19 | 20 | 21 | class TestList: 22 | def test_str(self): 23 | list = mixer.blend('boards.List') 24 | assert list.title == str(list), 'Should check the List name' 25 | 26 | def test_save(self): 27 | board = mixer.blend('boards.Board') 28 | list1 = mixer.blend('boards.List', board=board) 29 | list2 = mixer.blend('boards.List', board=board) 30 | assert list1.order == 2 ** 16 - 1 31 | assert list1.order == list2.order - (2 ** 16 - 1) 32 | list3 = mixer.blend('boards.List', board=board) 33 | list4 = mixer.blend('boards.List', board=board) 34 | list1.delete() 35 | list5 = mixer.blend('boards.List', board=board) 36 | assert list5.order == 5 * (2 ** 16 - 1) 37 | 38 | 39 | class TestItem: 40 | def test_str(self): 41 | item = mixer.blend('boards.Item') 42 | assert item.title == str(item) 43 | 44 | def test_save(self): 45 | list = mixer.blend('boards.List') 46 | item1 = mixer.blend('boards.Item', list=list) 47 | item2 = mixer.blend('boards.Item', list=list) 48 | assert item1.order == (2 ** 16 -1) 49 | assert item1.order == item2.order - (2 ** 16 - 1) 50 | item3 = mixer.blend('boards.Item', list=list) 51 | item4 = mixer.blend('boards.Item', list=list) 52 | item1.delete() 53 | item2.delete() 54 | item5 = mixer.blend('boards.Item', list=list) 55 | assert item5.order == 5 * (2 ** 16 -1) 56 | 57 | 58 | class TestLabel: 59 | def test_model(self): 60 | board = mixer.blend('boards.Board') 61 | assert board.pk == 1, 'Should create a Label instance' 62 | 63 | def test_str(self): 64 | label = mixer.blend('boards.Label') 65 | assert label.title == str(label), 'Should check the Label name' 66 | 67 | 68 | class TestComment: 69 | def test_model(self): 70 | comment = mixer.blend('boards.Comment') 71 | assert comment.pk == 1, 'Should create a Comment instance' 72 | 73 | 74 | class TestAttachment: 75 | def test_model(self): 76 | attachment = mixer.blend('boards.Attachment') 77 | assert attachment.pk == 1, 'Should create a Attachment instance' 78 | -------------------------------------------------------------------------------- /backend/users/models.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.contrib.auth.models import AbstractUser, UserManager 3 | from django.contrib.contenttypes.fields import GenericRelation 4 | from django.contrib.contenttypes.models import ContentType 5 | from django.core.validators import RegexValidator 6 | from django.db import models 7 | 8 | 9 | class CustomUserManager(UserManager): 10 | def get_by_natural_key(self, username): 11 | case_insensitive_username_field = '{}__iexact'.format( 12 | self.model.USERNAME_FIELD) 13 | return self.get(**{case_insensitive_username_field: username}) 14 | 15 | def filter(self, **kwargs): 16 | if 'email' in kwargs: 17 | kwargs['email__iexact'] = kwargs['email'] 18 | del kwargs['email'] 19 | 20 | if 'username' in kwargs: # Get by natural key not usef while registering, filter is 21 | kwargs['username__iexact'] = kwargs['username'] 22 | del kwargs['username'] 23 | return super(CustomUserManager, self).filter(**kwargs) 24 | 25 | def get(self, **kwargs): 26 | if 'email' in kwargs: 27 | kwargs['email__iexact'] = kwargs['email'] 28 | del kwargs['email'] 29 | 30 | if 'username' in kwargs: # When we use .get() explicitly. Get by natural key used for logging in 31 | kwargs['username__iexact'] = kwargs['username'] 32 | del kwargs['username'] 33 | return super(CustomUserManager, self).get(**kwargs) 34 | 35 | 36 | username_validator = RegexValidator( 37 | r'^[a-zA-Z0-9_\.]*$', 'Only alphanumeric characters, underscores, and periods are allowed in your username.') 38 | 39 | 40 | class User(AbstractUser): 41 | username = models.CharField( 42 | max_length=15, blank=False, null=False, unique=True, validators=[username_validator]) 43 | email = models.EmailField( 44 | max_length=255, blank=False, null=False, unique=True) 45 | first_name = models.CharField(max_length=255, blank=False, null=False) 46 | last_name = models.CharField(max_length=255, blank=False, null=False) 47 | profile_pic = models.ImageField(blank=True, upload_to='profile_pics') 48 | 49 | boards = GenericRelation( 50 | 'boards.Board', object_id_field='owner_id', content_type_field='owner_model') 51 | starred_boards = models.ManyToManyField('boards.Board', blank=True) 52 | 53 | objects = CustomUserManager() 54 | 55 | @property 56 | def full_name(self): 57 | return f'{self.first_name} {self.last_name}' 58 | 59 | def can_view_board(self, board): 60 | ProjectMembership = apps.get_model('projects', 'ProjectMembership') 61 | 62 | if board.owner_model == ContentType.objects.get(model='project'): 63 | try: 64 | pmem = ProjectMembership.objects.get( 65 | member=self, project__id=board.owner_id) 66 | except ProjectMembership.DoesNotExist: 67 | return False 68 | else: 69 | if board.owner_id != self.id: 70 | return False 71 | return True 72 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_common.scss: -------------------------------------------------------------------------------- 1 | .btn { 2 | background-color: $blue; 3 | border-radius: 7px; 4 | padding: 0.75em 1.25em; 5 | color: white; 6 | 7 | &:hover { 8 | opacity: 0.9; 9 | } 10 | 11 | &--secondary { 12 | background-color: #f2f2f2; 13 | color: darken($secondary-text, 20%); 14 | 15 | &:hover { 16 | background-color: darken(#f2f2f2, 6%); 17 | } 18 | } 19 | 20 | &--transparent { 21 | background-color: transparent; 22 | color: darken($secondary-text, 20%); 23 | 24 | &:hover { 25 | background-color: darken(#f2f2f2, 6%); 26 | } 27 | } 28 | 29 | &--active { 30 | // For home sidebar, when you're on that page 31 | background-color: $light-blue; 32 | color: $blue; 33 | 34 | &:hover { 35 | background-color: $light-blue; 36 | color: $blue; 37 | } 38 | } 39 | 40 | &--medium { 41 | padding: 0.625em 1em; 42 | font-size: 0.875rem; 43 | } 44 | 45 | &--small { 46 | padding: 0.5em 0.75em; 47 | font-size: 0.875rem; 48 | } 49 | 50 | &--disabled { 51 | cursor: not-allowed; 52 | color: lighten(#42526e, 25%); 53 | background-color: $border-color; 54 | 55 | &:hover { 56 | color: lighten(#42526e, 25%); 57 | background-color: $border-color; 58 | opacity: 1; 59 | } 60 | } 61 | } 62 | 63 | .member { 64 | border: 0; 65 | font-weight: 600; 66 | border-radius: 4px; 67 | 68 | width: 30px; 69 | height: 30px; 70 | line-height: 30px; 71 | text-align: center; 72 | vertical-align: middle; 73 | margin: 0 0.125em; 74 | 75 | &:first-of-type { 76 | margin-left: 0; 77 | } 78 | 79 | &:last-of-type { 80 | margin-right: 0; 81 | } 82 | 83 | &--red { 84 | background-color: $light-red; 85 | color: $red; 86 | } 87 | 88 | &--blue { 89 | background-color: $light-blue; 90 | color: $blue; 91 | } 92 | 93 | &--yellow { 94 | background-color: $light-yellow; 95 | color: $yellow; 96 | } 97 | 98 | &--add { 99 | border: 1px rgba($border-color, 0.75) dashed; 100 | color: rgba($border-color, 0.85); 101 | font-weight: 400; 102 | } 103 | 104 | &--large { 105 | width: 35px; 106 | height: 35px; 107 | line-height: 35px; 108 | } 109 | 110 | &--image { 111 | > img { 112 | width: 100%; 113 | height: 100%; 114 | object-fit: cover; 115 | border-radius: 4px; 116 | } 117 | } 118 | } 119 | 120 | .labels { 121 | display: flex; 122 | margin-bottom: 1em; 123 | 124 | &__label { 125 | font-weight: 900; 126 | margin: 0 0.25em; 127 | line-height: 1px; 128 | 129 | &:first-of-type { 130 | margin-left: 0; 131 | } 132 | 133 | &:last-of-type { 134 | margin-right: 0; 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/react,django 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=react,django 4 | 5 | ### Django ### 6 | *.log 7 | *.pot 8 | *.pyc 9 | __pycache__/ 10 | local_settings.py 11 | db.sqlite3 12 | db.sqlite3-journal 13 | media 14 | 15 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 16 | # in your Git repository. Update and uncomment the following line accordingly. 17 | # /staticfiles/ 18 | 19 | ### Django.Python Stack ### 20 | # Byte-compiled / optimized / DLL files 21 | *.py[cod] 22 | *$py.class 23 | 24 | # C extensions 25 | *.so 26 | 27 | # Distribution / packaging 28 | .Python 29 | build/ 30 | develop-eggs/ 31 | dist/ 32 | downloads/ 33 | eggs/ 34 | .eggs/ 35 | lib/ 36 | lib64/ 37 | parts/ 38 | sdist/ 39 | var/ 40 | wheels/ 41 | pip-wheel-metadata/ 42 | share/python-wheels/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | MANIFEST 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .nox/ 62 | .coverage 63 | .coverage.* 64 | .cache 65 | nosetests.xml 66 | coverage.xml 67 | *.cover 68 | *.py,cover 69 | .hypothesis/ 70 | .pytest_cache/ 71 | pytestdebug.log 72 | 73 | # Translations 74 | *.mo 75 | 76 | # Django stuff: 77 | 78 | # Flask stuff: 79 | instance/ 80 | .webassets-cache 81 | 82 | # Scrapy stuff: 83 | .scrapy 84 | 85 | # Sphinx documentation 86 | docs/_build/ 87 | doc/_build/ 88 | 89 | # PyBuilder 90 | target/ 91 | 92 | # Jupyter Notebook 93 | .ipynb_checkpoints 94 | 95 | # IPython 96 | profile_default/ 97 | ipython_config.py 98 | 99 | # pyenv 100 | .python-version 101 | 102 | # pipenv 103 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 104 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 105 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 106 | # install all needed dependencies. 107 | #Pipfile.lock 108 | 109 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 110 | __pypackages__/ 111 | 112 | # Celery stuff 113 | celerybeat-schedule 114 | celerybeat.pid 115 | 116 | # SageMath parsed files 117 | *.sage.py 118 | 119 | # Environments 120 | .env 121 | .venv 122 | env/ 123 | venv/ 124 | ENV/ 125 | env.bak/ 126 | venv.bak/ 127 | 128 | # Spyder project settings 129 | .spyderproject 130 | .spyproject 131 | 132 | # Rope project settings 133 | .ropeproject 134 | 135 | # mkdocs documentation 136 | /site 137 | 138 | # mypy 139 | .mypy_cache/ 140 | .dmypy.json 141 | dmypy.json 142 | 143 | # Pyre type checker 144 | .pyre/ 145 | 146 | # pytype static type analyzer 147 | .pytype/ 148 | 149 | ### react ### 150 | .DS_* 151 | logs 152 | **/*.backup.* 153 | **/*.back.* 154 | 155 | node_modules 156 | bower_components 157 | 158 | *.sublime* 159 | 160 | psd 161 | thumb 162 | sketch 163 | 164 | # End of https://www.toptal.com/developers/gitignore/api/react,django 165 | .vscode -------------------------------------------------------------------------------- /frontend/src/static/css/components/_header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | right: 0; 6 | z-index: 2; 7 | display: flex; 8 | align-items: center; 9 | justify-content: space-between; 10 | color: $secondary-text; 11 | background: white; 12 | 13 | &__section { 14 | &:nth-of-type(1) { 15 | flex: 1; 16 | } 17 | 18 | &:nth-of-type(3) { 19 | flex: 1; 20 | .header__list { 21 | width: fit-content; 22 | margin-left: auto; 23 | } 24 | } 25 | } 26 | 27 | &__logo { 28 | object-fit: cover; 29 | height: 35px; 30 | width: 114px; 31 | } 32 | 33 | &__list { 34 | display: flex; 35 | align-items: center; 36 | } 37 | 38 | &__li { 39 | padding: 1.5em 1.5em; 40 | max-height: 65px; 41 | 42 | &--search { 43 | display: flex; 44 | align-items: center; 45 | padding: 0.7em 0.7em; // As we highlight this when active, looks awkard if the whole height of header 46 | border-radius: 5px; 47 | background-color: white; 48 | transition: width 100ms ease; 49 | 50 | > .far { 51 | margin-right: 0.5em; 52 | } 53 | 54 | > input { 55 | outline: 0; 56 | border: 0; 57 | width: 100%; 58 | } 59 | } 60 | 61 | &--notifications { 62 | // Used by querySelector to position notifications modal 63 | position: relative; 64 | .header__unread { 65 | height: 7px; 66 | width: 7px; 67 | border-radius: 50%; 68 | background-color: $blue; 69 | position: absolute; 70 | top: 22px; 71 | right: 22px; 72 | } 73 | } 74 | 75 | &--active { 76 | // Only for use with search 77 | position: relative; 78 | z-index: 4; 79 | width: 300px; 80 | 81 | > .far { 82 | color: $blue; 83 | } 84 | } 85 | 86 | &--profile { 87 | display: flex; 88 | align-items: center; 89 | > .member { 90 | margin-right: 1em !important; 91 | } 92 | } 93 | 94 | &--border { 95 | border-left: 1px rgba($border-color, 0.55) solid; 96 | } 97 | } 98 | 99 | &--transparent { 100 | // If board image is set 101 | background-color: transparent; 102 | color: white; 103 | 104 | .header__li--search { 105 | background-color: hsla(0, 0%, 100%, 0.3); 106 | input { 107 | background-color: transparent; 108 | color: white; 109 | 110 | &::placeholder { 111 | color: rgba(white, 0.7); 112 | } 113 | } 114 | 115 | &:focus-within { 116 | background-color: white; 117 | color: $secondary-text; 118 | :not(i) { 119 | color: black; 120 | } 121 | } 122 | } 123 | 124 | .header__li--border { 125 | border: 0; 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_home.scss: -------------------------------------------------------------------------------- 1 | .home-wrapper { 2 | display: flex; 3 | background-color: #f6f7fb; 4 | padding: 100px 200px 0; 5 | width: 100vw; 6 | min-height: 100vh; 7 | height: 100%; 8 | } 9 | 10 | .home-menu { 11 | flex: 1; 12 | padding-right: 1.5em; 13 | 14 | &__section { 15 | display: flex; 16 | justify-content: space-between; 17 | align-items: center; 18 | margin-bottom: 0.25em; 19 | } 20 | 21 | ul { 22 | margin-bottom: 0.25em; 23 | } 24 | 25 | li .btn { 26 | display: block; 27 | width: 100%; 28 | margin: 0.35em 0; 29 | margin-left: -0.75em; 30 | 31 | &:first-of-type { 32 | margin-top: 0; 33 | } 34 | } 35 | } 36 | 37 | .home { 38 | flex: 5; 39 | padding-left: 1.5em; 40 | 41 | &__section { 42 | display: flex; 43 | justify-content: space-between; 44 | align-items: center; 45 | margin-bottom: 1.5em; 46 | 47 | i { 48 | margin-right: 0.5em; 49 | } 50 | 51 | .btn { 52 | margin: 0 0.25em; 53 | 54 | &:first-of-type { 55 | margin-left: 0; 56 | } 57 | 58 | &:last-of-type { 59 | margin-right: 0; 60 | } 61 | } 62 | } 63 | 64 | &__title { 65 | font-weight: 500; 66 | font-size: 1.25rem; 67 | } 68 | 69 | &__boards { 70 | display: flex; 71 | flex-wrap: wrap; 72 | margin-bottom: 1em; 73 | > .board-preview { 74 | color: black; 75 | margin: 0 1em 1.5em; 76 | width: calc(25% - 1.5em); 77 | 78 | &:nth-of-type(4n + 1) { 79 | margin-left: 0; 80 | } 81 | 82 | &:nth-of-type(4n) { 83 | margin-right: 0; 84 | } 85 | } 86 | } 87 | } 88 | 89 | .board-preview { 90 | background: white; 91 | padding: 1em; 92 | border-radius: 5px; 93 | border: 1px rgba($border-color, 0.55) solid; 94 | position: relative; 95 | overflow: hidden; 96 | 97 | &__star { 98 | position: absolute; 99 | top: 0.75em; 100 | right: -2em; 101 | color: white; 102 | transition: all 100ms ease; 103 | 104 | &:hover { 105 | transform: scale(1.1); 106 | } 107 | 108 | &--starred { 109 | right: 0.75em; 110 | color: #ffd700; 111 | } 112 | } 113 | 114 | &:hover { 115 | background: darken(white, 2%); 116 | 117 | .board-preview__star { 118 | right: 0.75em; 119 | } 120 | } 121 | 122 | &__title { 123 | margin-bottom: 1em; 124 | } 125 | 126 | &__members { 127 | display: flex; 128 | align-items: center; 129 | 130 | p { 131 | margin-left: 0.5em; 132 | font-weight: 300; 133 | color: $secondary-text; 134 | font-size: 0.75rem; 135 | } 136 | } 137 | 138 | &__image, 139 | &__color { 140 | width: calc(100% + 2em); // + Twice parent's padding 141 | height: 125px; 142 | margin-left: -1em; 143 | margin-top: -1em; 144 | margin-bottom: 1em; // Too close to title otherwise 145 | } 146 | 147 | &__image img { 148 | border-radius: 5px 5px 0 0; 149 | width: 100%; 150 | height: 100%; 151 | object-fit: cover; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /backend/boards/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.1 on 2020-09-20 15:28 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import django.utils.timezone 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Board', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('title', models.CharField(max_length=255)), 23 | ('description', models.TextField(blank=True)), 24 | ('image', models.ImageField(blank=True, upload_to='board_images')), 25 | ('created_at', models.DateTimeField(default=django.utils.timezone.now)), 26 | ], 27 | ), 28 | migrations.CreateModel( 29 | name='Item', 30 | fields=[ 31 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 32 | ('title', models.CharField(max_length=255)), 33 | ('description', models.TextField(blank=True)), 34 | ('image', models.ImageField(blank=True, upload_to='item_images')), 35 | ('created_at', models.DateTimeField(default=django.utils.timezone.now)), 36 | ], 37 | ), 38 | migrations.CreateModel( 39 | name='List', 40 | fields=[ 41 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 42 | ('title', models.CharField(max_length=255)), 43 | ('created_at', models.DateTimeField(default=django.utils.timezone.now)), 44 | ('board', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lists', to='boards.board')), 45 | ], 46 | ), 47 | migrations.CreateModel( 48 | name='Label', 49 | fields=[ 50 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 51 | ('title', models.CharField(max_length=255)), 52 | ('color', models.CharField(max_length=255)), 53 | ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='boards.item')), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name='Comment', 58 | fields=[ 59 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 60 | ('description', models.TextField(blank=True)), 61 | ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='boards.item')), 62 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL)), 63 | ], 64 | ), 65 | migrations.CreateModel( 66 | name='Attachment', 67 | fields=[ 68 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 69 | ('upload', models.FileField(upload_to='attachments')), 70 | ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='boards.item')), 71 | ], 72 | ), 73 | ] 74 | -------------------------------------------------------------------------------- /frontend/src/components/modals/ChangePermissionsModal.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { backendUrl } from "../../static/js/const"; 3 | import { authAxios } from "../../static/js/util"; 4 | 5 | const getChangePermissionsPosition = (permissionButton) => { 6 | if (!permissionButton) return null; 7 | return { 8 | top: 9 | permissionButton.getBoundingClientRect().y + 10 | permissionButton.getBoundingClientRect().height + 11 | 10 + 12 | "px", 13 | left: permissionButton.getBoundingClientRect().x + "px", 14 | }; 15 | }; 16 | 17 | const ChangePermissionsModal = ({ 18 | permissionButton, 19 | setShowModal, 20 | member, 21 | setProject, 22 | }) => { 23 | const changePermission = async (access_level) => { 24 | try { 25 | const { data } = await authAxios.put( 26 | backendUrl + `/projects/members/${member.id}/`, 27 | { 28 | access_level, 29 | } 30 | ); 31 | setProject((project) => { 32 | const updatedMembers = project.members.map((member) => 33 | member.id === data.id ? data : member 34 | ); 35 | project.members = updatedMembers; 36 | return { ...project }; 37 | }); 38 | } catch (error) { 39 | console.log(error); 40 | } 41 | setShowModal(false); 42 | }; 43 | return ( 44 |
48 |
49 |

Change Permissions

50 | 53 |
54 |
55 |
56 | {member.access_level === 2 ? ( 57 |

58 | Admin 59 | 60 |

61 | ) : ( 62 | 68 | )} 69 | 70 |

71 | Can view, create and edit project boards, change project 72 | settings, and invite new members. 73 |

74 |
75 |
76 | {member.access_level === 1 ? ( 77 |

78 | Normal 79 | 80 |

81 | ) : ( 82 | 88 | )} 89 |

90 | Can view, create and edit project boards. 91 |

92 |
93 |
94 |
95 | ); 96 | }; 97 | 98 | export default ChangePermissionsModal; 99 | -------------------------------------------------------------------------------- /backend/users/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mixer.backend.django import mixer 3 | from rest_framework.test import APIClient 4 | 5 | from ..models import User 6 | 7 | pytestmark = pytest.mark.django_db 8 | 9 | register_request_data = { 10 | 'username': 'john', 11 | 'email' : 'johndoe@gmail.com', 12 | 'first_name' : 'John', 13 | 'last_name' : 'Doe', 14 | 'password' : '}P-9(e,W' 15 | } 16 | 17 | class TestRegistration: 18 | def test_can_register(self): 19 | client = APIClient() 20 | response = client.post('/register/', register_request_data) 21 | assert response.status_code == 201 22 | 23 | def test_username_unique(self): 24 | client = APIClient() 25 | response = client.post('/register/', register_request_data) 26 | response = client.post('/register/', register_request_data) 27 | assert response.status_code == 400 28 | assert 'with this username already exists' in response.data['username'][0] 29 | 30 | def test_email_unique(self): 31 | client = APIClient() 32 | response = client.post('/register/', register_request_data) 33 | response = client.post('/register/', register_request_data) 34 | assert response.status_code == 400 35 | assert 'with this email already exists' in response.data['email'][0] 36 | 37 | def test_username_required(self): 38 | del register_request_data['username'] 39 | client = APIClient() 40 | response = client.post('/register/', register_request_data) 41 | assert response.status_code == 400 42 | assert 'is required' in response.data['username'][0] 43 | 44 | def test_email_required(self): 45 | del register_request_data['email'] 46 | client = APIClient() 47 | response = client.post('/register/', register_request_data) 48 | assert response.status_code == 400 49 | assert 'is required' in response.data['email'][0] 50 | 51 | def test_username_max_length(self): 52 | register_request_data['username'] = 'johnnyjohnsondoe' 53 | client = APIClient() 54 | response = client.post('/register/', register_request_data) 55 | assert response.status_code == 400 56 | assert 'no more than 15 characters' in response.data['username'][0] 57 | 58 | def test_username_regex(self): 59 | register_request_data['username'] = 'jdoe@gmail.com' 60 | client = APIClient() 61 | response = client.post('/register/', register_request_data) 62 | assert response.status_code == 400 63 | assert 'allowed in your username' in response.data['username'][0] 64 | 65 | login_request_data = { 66 | 'username': 'john', 67 | 'password' : '}P-9(e,W' 68 | } 69 | 70 | class TestLogin: 71 | def test_can_login(self): 72 | client = APIClient() 73 | user = mixer.blend(User, username=login_request_data['username']) 74 | user.set_password(login_request_data['password']) 75 | user.save() 76 | response = client.post('/token/', login_request_data) 77 | assert response.status_code == 200 78 | 79 | def test_can_login_with_email(self): 80 | client = APIClient() 81 | user = mixer.blend(User, username=login_request_data['username'], email='johndoe@gmail.com') 82 | user.set_password(login_request_data['password']) 83 | user.save() 84 | login_request_data['username'] = 'johndoe@gmail.com' 85 | response = client.post('/token/', login_request_data) 86 | assert response.status_code == 200 87 | 88 | def test_incorrect_password(self): 89 | client = APIClient() 90 | user = mixer.blend(User, username=login_request_data['username']) 91 | user.set_password('xdcftvygbh') 92 | user.save() 93 | response = client.post('/token/', login_request_data) 94 | assert response.status_code == 401 95 | assert 'No active account found' in response.data['detail'] 96 | -------------------------------------------------------------------------------- /frontend/src/static/img/greenface.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | green face 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /frontend/src/components/modals/BoardBackground.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import { 5 | convertUnsplashToOptions, 6 | getAddBoardStyle, 7 | } from "../../static/js/util"; 8 | import useAxiosGet from "../../hooks/useAxiosGet"; 9 | import { colors } from "../../static/js/const"; 10 | 11 | const BoardBackground = ({ 12 | position, 13 | setShowBoardModal, 14 | extraBackground, 15 | setExtraBackground, 16 | setSelectedBackground, 17 | }) => { 18 | const accessKey = process.env.REACT_APP_UNSPLASH_API_ACCESS_KEY; 19 | const { data } = useAxiosGet( 20 | `https://api.unsplash.com/photos?client_id=${accessKey}&page=2`, 21 | false 22 | ); 23 | const images = convertUnsplashToOptions(data); 24 | return ( 25 |
26 |
27 |

Board Background

28 | 31 |
32 |
33 |

Photos

34 |
    35 | {images.slice(0, 6).map((imageOption) => ( 36 |
  • 44 | 55 |
  • 56 | ))} 57 |
58 |
59 |
60 |

Colors

61 |
    62 | {colors.slice(0, 6).map((colorOption) => ( 63 |
  • 71 | 82 |
  • 83 | ))} 84 |
  • 88 | 89 |
  • 90 |
91 |
92 |
93 | ); 94 | }; 95 | 96 | export default BoardBackground; 97 | -------------------------------------------------------------------------------- /frontend/src/components/modals/CreateTeamModal.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import board from "../../static/img/board.svg"; 3 | import greenface from "../../static/img/greenface.svg"; 4 | import { modalBlurHandler, authAxios } from "../../static/js/util"; 5 | import { backendUrl } from "../../static/js/const"; 6 | 7 | import { useForm } from "react-hook-form"; 8 | 9 | const CreateTeamModal = ({ setShowModal, addProject }) => { 10 | useEffect(modalBlurHandler(setShowModal), []); 11 | const { register, handleSubmit, errors, watch } = useForm(); 12 | const titleValue = watch("title", ""); 13 | 14 | const animateFaces = () => { 15 | const face1 = document.querySelector(".create-team__face--1"); 16 | if (titleValue !== "") { 17 | face1.style.top = "230px"; 18 | face1.style.left = "60px"; 19 | } 20 | }; 21 | 22 | const onSubmit = async (data) => { 23 | const invitedMembers = 24 | data.members !== "" 25 | ? data.members.split(",").map((user) => user.trim()) // usernames and emails 26 | : []; 27 | 28 | try { 29 | const { data: resData } = await authAxios.post( 30 | backendUrl + "/projects/", 31 | data 32 | ); 33 | if (invitedMembers.length !== 0) { 34 | await authAxios.post( 35 | backendUrl + `/projects/${resData.id}/invite/`, 36 | { 37 | users: invitedMembers, 38 | } 39 | ); 40 | } 41 | addProject(resData); 42 | } catch (error) { 43 | console.log(error); 44 | } 45 | setShowModal(false); 46 | }; 47 | 48 | return ( 49 |
50 |
51 |

Start a Project

52 |

53 | Boost your productivity by making it easier for everyone to 54 | access boards in one location. 55 |

56 | 57 |
58 | 59 | 66 | 67 | 68 | 73 | 74 | 75 | 81 | 82 | {titleValue.trim() !== "" ? ( 83 | 86 | ) : ( 87 | 90 | )} 91 |
92 |
93 |
94 | 97 | 98 | 102 |
103 |
104 | ); 105 | }; 106 | 107 | export default CreateTeamModal; 108 | -------------------------------------------------------------------------------- /frontend/src/components/auth/RegisterForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import axios from "axios"; 3 | 4 | import { useHistory } from "react-router-dom"; 5 | import { backendUrl } from "../../static/js/const"; 6 | import { useForm } from "react-hook-form"; 7 | 8 | const TabOne = ({ display, register }) => { 9 | const style = display ? { display: "block" } : { display: "none" }; 10 | return ( 11 |
12 | 19 | 26 | 33 |
34 | ); 35 | }; 36 | 37 | const TabTwo = ({ display, register }) => { 38 | const style = display ? { display: "block" } : { display: "none" }; 39 | return ( 40 |
41 | 48 | 53 | 60 |
61 | ); 62 | }; 63 | 64 | const RegisterForm = ({setErrMsgs}) => { 65 | const { register, handleSubmit, watch } = useForm(); 66 | const [isTabOne, setIsTabOne] = useState(true); 67 | const firstName = watch("first_name", ""); 68 | const lastName = watch("last_name", ""); 69 | const username = watch("username", ""); 70 | const email = watch("email", ""); 71 | const passw = watch("password", ""); 72 | const history = useHistory(); 73 | 74 | const onSubmit = async (data) => { 75 | const url = `${backendUrl}/register/`; 76 | try { 77 | await axios.post(url, data); 78 | history.push("/login"); 79 | } catch (err) { 80 | if (err.response?.status === 400) { 81 | setErrMsgs({signup: true , err: true, msgs: err.response.data}); 82 | }else { 83 | setErrMsgs({signup: false, err: true, msgs: { Connection: 'Refused', Server: 'Maybe Down'}}); 84 | } 85 | } 86 | }; 87 | 88 | const validTabContent = () => { 89 | if (isTabOne) 90 | return [firstName, lastName, username] 91 | .map((x) => x.trim() !== "") 92 | .reduce((a, b) => a && b, true); 93 | else return passw.trim() !== "" && passw.length >= 8 && email.trim() !== ""; 94 | }; 95 | 96 | const getClass = () => { 97 | let str = "btn"; 98 | if (!validTabContent()) { 99 | str += " btn--disabled"; 100 | } 101 | return str; 102 | }; 103 | 104 | return ( 105 | <> 106 |
107 |
108 | 109 | 110 |
111 | {!isTabOne ? ( 112 | 120 | ) : null} 121 | 129 |
130 |
131 |
132 | 133 | ); 134 | }; 135 | 136 | export default RegisterForm; 137 | -------------------------------------------------------------------------------- /frontend/src/components/modals/NotificationsModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { backendUrl } from "../../static/js/const"; 3 | import { authAxios, timeSince } from "../../static/js/util"; 4 | import nonotif from "../../static/img/nonotif.svg"; 5 | import ProfilePic from "../boards/ProfilePic"; 6 | import Labels from "../boards/Labels"; 7 | 8 | const getNotificationsPosition = () => { 9 | const bellElem = document.querySelector(".header__li--notifications"); 10 | return { 11 | top: 12 | bellElem.getBoundingClientRect().y + 13 | bellElem.getBoundingClientRect().height + 14 | 10 + 15 | "px", 16 | right: "10px", 17 | left: "unset", 18 | }; 19 | }; 20 | 21 | const NotificationsModal = ({ 22 | setShowModal, 23 | notifications, 24 | setNotifications, 25 | }) => { 26 | const [showAll, setShowAll] = useState(false); // See all or just unread? 27 | 28 | const notificationsToShow = showAll 29 | ? notifications || [] 30 | : (notifications || []).filter( 31 | (notification) => notification.unread === true 32 | ); 33 | 34 | const markAllRead = async () => { 35 | await authAxios.post(`${backendUrl}/notifications/`); 36 | const newNotifications = notifications.map((notification) => ({ 37 | ...notification, 38 | unread: false, 39 | })); 40 | 41 | setNotifications(newNotifications); 42 | }; 43 | 44 | return ( 45 |
49 |
50 |

Notifications

51 | 54 |
55 |
56 |
57 | 60 | {!showAll && notificationsToShow.length !== 0 && ( 61 | 62 | )} 63 |
64 | {notificationsToShow.map((notification) => ( 65 | 66 | ))} 67 | {notificationsToShow.length === 0 && ( 68 |
69 | 70 |

No Notifications

71 |
72 | )} 73 |
74 |
75 | ); 76 | }; 77 | 78 | const appendTargetTitleVerbs = [ 79 | "assigned you to", 80 | "invited you to", 81 | "made you admin of", 82 | ]; 83 | 84 | const formatNotification = (notification) => { 85 | if (appendTargetTitleVerbs.includes(notification.verb)) 86 | return `${notification.verb} ${notification.target.title}`; 87 | else if (notification.verb === "commented") 88 | return `${notification.action_object.body}`; 89 | }; 90 | 91 | const iconMap = { 92 | "assigned you to": "fal fa-user-plus", 93 | "invited you to": "fal fa-paper-plane", 94 | "made you admin of": "fal fa-arrow-up", 95 | commented: "fal fa-comment", 96 | }; 97 | 98 | const Notification = ({ notification }) => { 99 | const { actor, verb, target, target_model, created_at } = notification; 100 | 101 | return ( 102 |
103 | {target_model === "Item" && ( 104 |
105 | 106 |

{target.title}

107 |
108 | )} 109 |
110 | 111 |

{actor.full_name}

112 |

113 | {timeSince(created_at)} 114 |

115 |
116 | 117 |

118 | 119 | {formatNotification(notification)} 120 |

121 |
122 | ); 123 | }; 124 | 125 | export default NotificationsModal; 126 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_project.scss: -------------------------------------------------------------------------------- 1 | .team { 2 | &__header { 3 | padding: 100px 50px 0; 4 | background-color: $grey; 5 | width: 100%; 6 | border-bottom: 1px solid $border-color; 7 | } 8 | 9 | &__header-content { 10 | width: fit-content; 11 | margin: 0 auto; 12 | } 13 | 14 | &__header-top { 15 | display: flex; 16 | margin-bottom: 2em; 17 | 18 | img { 19 | border-radius: 5px; 20 | width: 80px; 21 | height: 80px; 22 | object-fit: cover; 23 | } 24 | } 25 | 26 | &__profile { 27 | margin-left: 1.5em; 28 | display: flex; 29 | flex-direction: column; 30 | 31 | p { 32 | font-size: 1.5rem; 33 | font-weight: 600; 34 | } 35 | 36 | button { 37 | margin-top: auto; 38 | } 39 | } 40 | 41 | &__header-bottom { 42 | display: flex; 43 | } 44 | 45 | &__tab { 46 | padding: 0.75em 1.25em; 47 | border-radius: 5px 5px 0 0; 48 | background-color: #dfe1e6; 49 | margin-right: 0.25em; 50 | font-weight: 500; 51 | cursor: pointer; 52 | 53 | &:hover { 54 | background-color: #c1c7d0; 55 | } 56 | 57 | &--active { 58 | background-color: white; 59 | cursor: default; 60 | border: 1px solid $border-color; 61 | border-bottom-color: white; 62 | 63 | position: relative; 64 | top: 1px; 65 | 66 | &:hover { 67 | background-color: white; 68 | } 69 | } 70 | } 71 | 72 | &__body { 73 | width: fit-content; 74 | margin: 0 auto; 75 | padding: 40px 100px 20px; 76 | } 77 | 78 | &__boards { 79 | width: 900px; 80 | display: flex; 81 | flex-wrap: wrap; 82 | margin-bottom: 1em; 83 | > .board-preview { 84 | color: black; 85 | margin: 0 1em 1.5em; 86 | width: calc(25% - 1.5em); 87 | 88 | &:nth-of-type(4n + 1) { 89 | margin-left: 0; 90 | } 91 | 92 | &:nth-of-type(4n) { 93 | margin-right: 0; 94 | } 95 | } 96 | } 97 | 98 | &__members-header { 99 | font-weight: 600; 100 | font-size: 1.125rem; 101 | display: flex; 102 | justify-content: space-between; 103 | width: 900px; 104 | margin-bottom: 2.5em; 105 | 106 | i { 107 | margin-right: 0.5em; 108 | } 109 | } 110 | 111 | &__member { 112 | display: flex; 113 | align-items: center; 114 | margin-bottom: 1.25em; 115 | } 116 | 117 | &__member-info { 118 | margin-left: 1em; 119 | } 120 | 121 | &__member-name { 122 | font-weight: 500; 123 | margin-bottom: 0.25em; 124 | } 125 | 126 | &__member-username { 127 | font-weight: 300; 128 | color: $secondary-text; 129 | font-size: 0.875rem; 130 | } 131 | 132 | &__member-buttons { 133 | display: flex; 134 | align-items: center; 135 | margin-left: auto; 136 | position: relative; 137 | 138 | p { 139 | right: calc(95px + 1em); 140 | position: absolute; 141 | min-width: 69px; // So admin and member are same width 142 | } 143 | 144 | button { 145 | text-align: left; 146 | margin-left: 1em; 147 | 148 | &:last-of-type { 149 | min-width: 95px; // So leave and remove are same width 150 | } 151 | } 152 | 153 | i { 154 | margin-right: 0.5em; 155 | } 156 | } 157 | 158 | &__edit-form { 159 | margin-left: 1.5em; 160 | width: 200px; 161 | label { 162 | display: block; 163 | font-size: 0.75rem; 164 | font-weight: 500; 165 | margin-bottom: 0.3em; 166 | color: darken($secondary-text, 20%); 167 | } 168 | 169 | input { 170 | width: 100%; 171 | padding: 0.5em; 172 | margin-bottom: 1.4em; 173 | border: 1px darken($border-color, 40%) solid; 174 | 175 | &:focus { 176 | border: 1px $blue solid; 177 | } 178 | } 179 | 180 | textarea { 181 | width: 100%; 182 | padding: 0.5em; 183 | height: 60px; 184 | resize: none; 185 | border: 1px darken($border-color, 40%) solid; 186 | margin-bottom: 1.4em; 187 | 188 | &:focus { 189 | border: 1px $blue solid; 190 | } 191 | } 192 | 193 | button { 194 | margin-right: 0.5em; 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /backend/trello/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for trello project. 3 | 4 | Generated by 'django-admin startproject' using Django 3.1.1. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.1/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/3.1/ref/settings/ 11 | """ 12 | import os 13 | from datetime import timedelta 14 | 15 | from pathlib import Path 16 | 17 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 18 | BASE_DIR = Path(__file__).resolve().parent.parent 19 | 20 | 21 | # Quick-start development settings - unsuitable for production 22 | # See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ 23 | 24 | # SECURITY WARNING: keep the secret key used in production secret! 25 | SECRET_KEY = 'u&3va&+ye)j2-m1@t^99tvw@tggdy#mtcayv76r_dj)9as4*5$' 26 | 27 | # SECURITY WARNING: don't run with debug turned on in production! 28 | DEBUG = True 29 | 30 | ALLOWED_HOSTS = [] 31 | 32 | 33 | # Application definition 34 | 35 | INSTALLED_APPS = [ 36 | 'django.contrib.admin', 37 | 'django.contrib.auth', 38 | 'django.contrib.contenttypes', 39 | 'django.contrib.sessions', 40 | 'django.contrib.messages', 41 | 'django.contrib.staticfiles', 42 | 'rest_framework', 43 | 'users', 44 | 'projects.apps.ProjectsConfig', 45 | 'boards.apps.BoardsConfig', 46 | 'drf_yasg', 47 | 'corsheaders' 48 | ] 49 | 50 | MIDDLEWARE = [ 51 | 'corsheaders.middleware.CorsMiddleware', 52 | 'django.middleware.security.SecurityMiddleware', 53 | 'django.contrib.sessions.middleware.SessionMiddleware', 54 | 'django.middleware.common.CommonMiddleware', 55 | 'django.middleware.csrf.CsrfViewMiddleware', 56 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 57 | 'django.contrib.messages.middleware.MessageMiddleware', 58 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 59 | ] 60 | 61 | ROOT_URLCONF = 'trello.urls' 62 | 63 | TEMPLATES = [ 64 | { 65 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 66 | 'DIRS': [], 67 | 'APP_DIRS': True, 68 | 'OPTIONS': { 69 | 'context_processors': [ 70 | 'django.template.context_processors.debug', 71 | 'django.template.context_processors.request', 72 | 'django.contrib.auth.context_processors.auth', 73 | 'django.contrib.messages.context_processors.messages', 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | WSGI_APPLICATION = 'trello.wsgi.application' 80 | 81 | 82 | # Database 83 | # https://docs.djangoproject.com/en/3.1/ref/settings/#databases 84 | 85 | DATABASES = { 86 | 'default': { 87 | 'ENGINE': 'django.db.backends.sqlite3', 88 | 'NAME': BASE_DIR / 'db.sqlite3', 89 | } 90 | } 91 | 92 | AUTHENTICATION_BACKENDS = [ 93 | 'django.contrib.auth.backends.ModelBackend', 'users.auth.EmailBackend'] 94 | 95 | # Password validation 96 | # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators 97 | 98 | AUTH_PASSWORD_VALIDATORS = [ 99 | { 100 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 101 | }, 102 | { 103 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 104 | }, 105 | { 106 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 107 | }, 108 | { 109 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 110 | }, 111 | ] 112 | 113 | 114 | # Internationalization 115 | # https://docs.djangoproject.com/en/3.1/topics/i18n/ 116 | 117 | LANGUAGE_CODE = 'en-us' 118 | 119 | TIME_ZONE = 'UTC' 120 | 121 | USE_I18N = True 122 | 123 | USE_L10N = True 124 | 125 | USE_TZ = True 126 | 127 | 128 | # Static files (CSS, JavaScript, Images) 129 | # https://docs.djangoproject.com/en/3.1/howto/static-files/ 130 | 131 | EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' 132 | 133 | STATIC_URL = '/static/' 134 | 135 | MEDIA_ROOT = os.path.join(BASE_DIR, 'media') 136 | MEDIA_URL = '/media/' 137 | 138 | AUTH_USER_MODEL = 'users.User' 139 | 140 | REST_FRAMEWORK = { 141 | 'DEFAULT_AUTHENTICATION_CLASSES': [ 142 | 'rest_framework_simplejwt.authentication.JWTAuthentication', 143 | ], 144 | 'DEFAULT_PERMISSION_CLASSES': [ 145 | 'rest_framework.permissions.IsAuthenticated', 146 | ] 147 | } 148 | 149 | SIMPLE_JWT = { 150 | 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), 151 | 'REFRESH_TOKEN_LIFETIME': timedelta(days=360) 152 | } 153 | 154 | 155 | SWAGGER_SETTINGS = { 156 | 'token_type': 'Bearer', 157 | 'SECURITY_DEFINITIONS': { 158 | 'Bearer': { 159 | 'type': 'apiKey', 160 | 'name': 'Authorization', 161 | 'in': 'header' 162 | } 163 | } 164 | } 165 | 166 | CORS_ALLOWED_ORIGINS = ['http://localhost:3000'] 167 | 168 | REDIS_HOST = 'localhost' 169 | REDIS_PORT = 6380 170 | REDIS_DB = 0 171 | -------------------------------------------------------------------------------- /frontend/src/components/headers/Header.js: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | useEffect, 5 | useContext, 6 | useCallback, 7 | } from "react"; 8 | import _ from "lodash"; 9 | import logo from "../../static/img/logo2.png"; 10 | import SearchModal from "../modals/SearchModal"; 11 | import ProfilePic from "../boards/ProfilePic"; 12 | import { Link } from "react-router-dom"; 13 | import useAxiosGet from "../../hooks/useAxiosGet"; 14 | import useBlurSetState from "../../hooks/useBlurSetState"; 15 | import { handleBackgroundBrightness } from "../../static/js/util"; 16 | import globalContext from "../../context/globalContext"; 17 | import NotificationsModal from "../modals/NotificationsModal"; 18 | 19 | const Header = (props) => { 20 | const { authUser, board } = useContext(globalContext); 21 | 22 | const [searchQuery, setSearchQuery] = useState(""); //This variable keeps track of what to show in the search bar 23 | const [backendQuery, setBackendQuery] = useState(""); //This variable is used to query the backend, debounced 24 | const delayedQuery = useCallback( 25 | _.debounce((q) => setBackendQuery(q), 500), 26 | [] 27 | ); 28 | const [showSearch, setShowSearch] = useState(false); 29 | const searchElem = useRef(null); 30 | const [showNotifications, setShowNotifications] = useState(false); 31 | useBlurSetState(".label-modal", showNotifications, setShowNotifications); 32 | 33 | useEffect(() => { 34 | if (searchQuery !== "") setShowSearch(true); 35 | else if (searchQuery === "" && showSearch) setShowSearch(false); 36 | }, [searchQuery]); 37 | 38 | const { data: notifications, setData: setNotifications } = useAxiosGet( 39 | "/notifications/" 40 | ); 41 | 42 | const onBoardPage = props.location.pathname.split("/")[1] === "b"; 43 | const [isBackgroundDark, setIsBackgroundDark] = useState(false); 44 | useEffect(handleBackgroundBrightness(board, setIsBackgroundDark), [board]); 45 | 46 | return ( 47 | <> 48 |
55 |
56 |
    57 |
  • 58 | 59 | Boards 60 | 61 |
  • 62 |
  • 68 | {" "} 69 | { 73 | setSearchQuery(e.target.value); 74 | delayedQuery(e.target.value); 75 | }} 76 | /> 77 |
  • 78 |
79 |
80 |
81 | 82 | 83 | 84 |
85 |
86 |
    87 |
  • 88 | 89 | Hello, {authUser.full_name.replace(/ .*/, "")} 90 |
  • 91 |
  • 92 | 95 | {(notifications || []).find( 96 | (notification) => notification.unread == true 97 | ) &&
    } 98 |
  • 99 |
  • 100 | 101 | 102 | 103 |
  • 104 |
105 |
106 |
107 |
108 | {showSearch && ( 109 | 114 | )} 115 | {showNotifications && ( 116 | 121 | )} 122 | 123 | ); 124 | }; 125 | 126 | export default Header; 127 | -------------------------------------------------------------------------------- /frontend/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_modals.scss: -------------------------------------------------------------------------------- 1 | .label-modal { 2 | background-color: white; 3 | position: fixed; 4 | left: 40%; 5 | top: 20%; 6 | width: 320px; 7 | display: flex; 8 | flex-direction: column; 9 | z-index: 5; 10 | font-weight: 600; 11 | border-radius: 5px; 12 | 13 | &--bg { 14 | width: 20em; 15 | .label-modal__header { 16 | margin-bottom: 0; 17 | } 18 | 19 | .label-modal__create-label { 20 | > button { 21 | width: 5em; 22 | height: 3.5em; 23 | background-size: cover; 24 | } 25 | } 26 | 27 | .label-modal__create-block { 28 | height: 10em; 29 | overflow-y: scroll; 30 | } 31 | 32 | .label-modal__labels-head { 33 | margin-top: 0.8em; 34 | } 35 | } 36 | 37 | &__header { 38 | padding: 1.2em; 39 | display: flex; 40 | justify-content: center; 41 | color: $secondary-text; 42 | 43 | p { 44 | margin-left: auto; 45 | } 46 | 47 | button { 48 | margin-left: auto; 49 | } 50 | 51 | > i:hover { 52 | filter: brightness(70%); 53 | } 54 | } 55 | 56 | &__labels-head { 57 | color: $secondary-text; 58 | font-weight: 600; 59 | margin-left: 1.2em; 60 | margin-top: 1.2em; 61 | height: 1rem; 62 | } 63 | 64 | &__labels-block { 65 | overflow-x: hidden; 66 | overflow-y: scroll; 67 | display: flex; 68 | flex-direction: column; 69 | height: 15em; 70 | margin-bottom: 1.2em; 71 | } 72 | 73 | &__label { 74 | margin: 0.3em 1.2em; 75 | height: 2.5em; 76 | display: flex; 77 | flex-direction: row; 78 | justify-content: space-evenly; 79 | 80 | > p { 81 | border-radius: 0.2em; 82 | height: 100%; 83 | width: 90%; 84 | padding-top: 0.8em; 85 | text-align: center; 86 | color: white; 87 | } 88 | 89 | > i { 90 | float: right; 91 | padding-top: 0.8em; 92 | color: $secondary-text; 93 | } 94 | 95 | > i:hover { 96 | filter: brightness(70%); 97 | } 98 | > p:hover, 99 | &--selected { 100 | filter: brightness(90%); 101 | } 102 | } 103 | 104 | &__create { 105 | border: 0; 106 | height: 2em; 107 | margin: 0.6em 1.2em; 108 | border-radius: 0.2em; 109 | color: black; 110 | } 111 | 112 | &__create-block { 113 | display: flex; 114 | flex-flow: row; 115 | flex-wrap: wrap; 116 | margin: 0.6em 0.4em; 117 | padding-left: 0.6em; 118 | } 119 | 120 | &__create-label { 121 | margin-bottom: 0.5em; 122 | margin-right: 0.7em; 123 | 124 | > button { 125 | width: 4.82em; 126 | height: 3em; 127 | border: 0; 128 | border-radius: 4px; 129 | > i { 130 | color: white; 131 | } 132 | } 133 | 134 | > button:hover { 135 | filter: brightness(90%); 136 | } 137 | 138 | &--selected { 139 | filter: brightness(90%); 140 | } 141 | &:last-of-type { 142 | > button { 143 | background-color: white; 144 | } 145 | > button:hover { 146 | background-color: lighten($secondary-text, 40); 147 | } 148 | } 149 | } 150 | 151 | &__create-button { 152 | background-color: $blue; 153 | border: 0; 154 | margin-top: 1.2em; 155 | margin-bottom: 1.2em; 156 | margin-left: 1.2em; 157 | margin-right: auto; 158 | font-weight: 600; 159 | } 160 | 161 | &--create { 162 | width: 25em; 163 | } 164 | &__error { 165 | color: darken($blue, 60); 166 | margin: 0.5em 1.3em 1.3em 1.3em; 167 | > li { 168 | margin-bottom: 0.4em; 169 | font-weight: 300; 170 | } 171 | } 172 | 173 | // Vik's additions 174 | &--shadow { 175 | box-shadow: 0 8px 16px -4px rgba(9, 30, 66, 0.25), 176 | 0 0 0 1px rgba(9, 30, 66, 0.08); 177 | border: 1px solid $border-color; 178 | } 179 | 180 | &--large { 181 | width: 500px; 182 | } 183 | 184 | &__content { 185 | padding: 0 1em 1em; 186 | 187 | button { 188 | width: 100%; 189 | } 190 | } 191 | 192 | &__title { 193 | margin-bottom: 1em; 194 | } 195 | 196 | &__invite-header { 197 | font-size: 0.875rem; 198 | margin-bottom: 0.625em; 199 | 200 | i { 201 | margin-right: 0.5em; 202 | } 203 | } 204 | 205 | &__input { 206 | padding: 0.5em; 207 | width: 100%; 208 | margin-bottom: 1.4em; 209 | border: 1px darken($border-color, 40%) solid; 210 | 211 | &:focus { 212 | border: 1px $blue solid; 213 | } 214 | } 215 | 216 | &__option { 217 | &:first-of-type { 218 | margin-bottom: 1em; 219 | } 220 | 221 | i { 222 | color: $blue; 223 | margin-left: 0.5em; 224 | } 225 | } 226 | 227 | &__option-header { 228 | color: black; 229 | font-weight: 500; 230 | margin-bottom: 0.25em; 231 | width: fit-content !important; 232 | } 233 | 234 | &__option-subtitle { 235 | font-weight: 400; 236 | color: $secondary-text; 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /backend/boards/serializers.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist 3 | from django.urls import Resolver404 4 | from django.urls.base import resolve, reverse 5 | from django.utils.module_loading import import_string 6 | from projects.models import Project 7 | from rest_framework import serializers 8 | from rest_framework.fields import Field 9 | from users.models import User 10 | from users.serializers import UserSerializer 11 | 12 | from .models import Attachment, Board, Comment, Item, Label, List, Notification 13 | 14 | 15 | class LabelSerializer(serializers.ModelSerializer): 16 | 17 | class Meta: 18 | model = Label 19 | exclude = ('board',) 20 | 21 | 22 | class CommentSerializer(serializers.ModelSerializer): 23 | author = UserSerializer(read_only=True) 24 | 25 | class Meta: 26 | model = Comment 27 | exclude = ['item'] 28 | 29 | 30 | class AttachmentSerializer(serializers.ModelSerializer): 31 | 32 | class Meta: 33 | model = Attachment 34 | fields = '__all__' 35 | 36 | 37 | class ItemSerializer(serializers.ModelSerializer): 38 | labels = LabelSerializer(many=True, read_only=True) 39 | attachments = AttachmentSerializer(many=True, read_only=True) 40 | assigned_to = serializers.SerializerMethodField() 41 | 42 | class Meta: 43 | model = Item 44 | exclude = ['list'] 45 | 46 | def get_assigned_to(self, obj): 47 | queryset = obj.assigned_to.all() 48 | return UserSerializer(queryset, many=True).data 49 | 50 | 51 | class ListSerializer(serializers.ModelSerializer): 52 | items = serializers.SerializerMethodField() 53 | 54 | class Meta: 55 | model = List 56 | exclude = ['board'] 57 | 58 | def get_items(self, obj): 59 | queryset = Item.objects.filter(list=obj).order_by('order') 60 | return ItemSerializer(queryset, many=True).data 61 | 62 | 63 | # For homepage, exclude lists 64 | class ShortBoardSerializer(serializers.ModelSerializer): 65 | owner = serializers.SerializerMethodField() 66 | is_starred = serializers.SerializerMethodField() 67 | list_count = serializers.SerializerMethodField() 68 | item_count = serializers.SerializerMethodField() 69 | 70 | class Meta: 71 | model = Board 72 | fields = ['id', 'title', 'image', 'image_url', 73 | 'color', 'owner', 'is_starred', 'list_count', 'item_count'] 74 | 75 | def get_is_starred(self, obj): 76 | request_user = self.context.get('request').user 77 | return request_user.starred_boards.filter(pk=obj.pk).exists() 78 | 79 | def get_owner(self, obj): 80 | object_app = obj.owner._meta.app_label 81 | object_name = obj.owner._meta.object_name 82 | if object_name == 'Project': 83 | object_name = 'Short' + object_name 84 | serializer_module_path = f'{object_app}.serializers.{object_name}Serializer' 85 | serializer_class = import_string(serializer_module_path) 86 | return serializer_class(obj.owner).data 87 | 88 | def get_list_count(self, obj): 89 | return List.objects.filter(board=obj).count() 90 | 91 | def get_item_count(self, obj): 92 | lists = List.objects.filter(board=obj) 93 | return Item.objects.filter(list__in=lists).count() 94 | 95 | def validate(self, data): 96 | background_keys = ["image", "image_url", "color"] 97 | if any(item in data.keys() for item in background_keys) == False: 98 | raise serializers.ValidationError( 99 | "A board background must be provided") 100 | 101 | return data 102 | 103 | 104 | class BoardSerializer(ShortBoardSerializer): 105 | lists = serializers.SerializerMethodField() 106 | 107 | class Meta: 108 | model = Board 109 | fields = ['id', 'title', 'description', 'image', 'image_url', 110 | 'color', 'created_at', 'owner', 'lists', 'is_starred', ] 111 | 112 | def get_lists(self, obj): 113 | queryset = List.objects.filter(board=obj).order_by('order') 114 | return ListSerializer(queryset, many=True).data 115 | 116 | def validate(self, data): 117 | return data # No need to pass in image/image_url/color while editing board 118 | 119 | 120 | class NotificationSerializer(serializers.ModelSerializer): 121 | actor = UserSerializer(read_only=True) 122 | target_model = serializers.SerializerMethodField() 123 | target = serializers.SerializerMethodField() 124 | action_object = serializers.SerializerMethodField() 125 | 126 | class Meta: 127 | model = Notification 128 | fields = ['id', 'actor', 'verb', 'target_model', 129 | 'target', 'action_object', 'unread', 'created_at'] 130 | 131 | def get_target_model(self, obj): 132 | object_name = obj.target._meta.object_name 133 | return object_name 134 | 135 | def get_target(self, obj): 136 | object_app = obj.target._meta.app_label 137 | object_name = obj.target._meta.object_name 138 | if object_name == 'Project': 139 | object_name = 'Short' + object_name 140 | serializer_module_path = f'{object_app}.serializers.{object_name}Serializer' 141 | serializer_class = import_string(serializer_module_path) 142 | return serializer_class(obj.target).data 143 | 144 | def get_action_object(self, obj): 145 | object_app = obj.action_object._meta.app_label 146 | object_name = obj.action_object._meta.object_name 147 | serializer_module_path = f'{object_app}.serializers.{object_name}Serializer' 148 | serializer_class = import_string(serializer_module_path) 149 | return serializer_class(obj.action_object).data 150 | -------------------------------------------------------------------------------- /frontend/src/components/modals/AddBoardModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef, useMemo } from "react"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | import BoardBackground from "./BoardBackground"; 5 | import { getEditControlsSidePosition } from "../boards/Card"; 6 | import { 7 | modalBlurHandler, 8 | getBoardBackgroundOptions, 9 | authAxios, 10 | getAddBoardStyle, 11 | } from "../../static/js/util"; 12 | import useAxiosGet from "../../hooks/useAxiosGet"; 13 | import { backendUrl } from "../../static/js/const"; 14 | 15 | const getBackgroundModalPosition = (boardElem) => { 16 | // pass in ref.current 17 | if (!boardElem) return null; 18 | return { 19 | top: boardElem.getBoundingClientRect().y + "px", 20 | left: 21 | boardElem.getBoundingClientRect().x + 22 | boardElem.getBoundingClientRect().width - 23 | 200 + 24 | "px", 25 | }; 26 | }; 27 | 28 | const AddBoardModal = ({ setShowAddBoardModal, addBoard, project }) => { 29 | const [selectedBackground, setSelectedBackground] = useState(0); 30 | const [extraBackground, setExtraBackground] = useState(null); // Did we choose something from the BoardBackground modal? 31 | const [title, setTitle] = useState(""); 32 | const [showBoardModal, setShowBoardModal] = useState(false); 33 | const boardElem = useRef(null); 34 | useEffect(modalBlurHandler(setShowAddBoardModal), []); 35 | 36 | const onSubmit = async (e) => { 37 | e.preventDefault(); 38 | 39 | const bg = options[selectedBackground]; 40 | const formData = { title }; 41 | if (project !== 0) formData.project = project; 42 | if (bg[1]) { 43 | // Image_url 44 | formData.image_url = bg[2]; 45 | } else { 46 | // color 47 | formData.color = bg[0].substring(1); // We don't store # char in backend 48 | } 49 | const { data } = await authAxios.post( 50 | `${backendUrl}/boards/`, 51 | formData 52 | ); 53 | addBoard(data); 54 | setShowAddBoardModal(false); 55 | }; 56 | 57 | const accessKey = process.env.REACT_APP_UNSPLASH_API_ACCESS_KEY; 58 | const { data } = useAxiosGet( 59 | `https://api.unsplash.com/photos?client_id=${accessKey}`, 60 | false 61 | ); 62 | const options = useMemo(() => getBoardBackgroundOptions(data), [data]); // So we don't reshuffle on state change 63 | if (extraBackground) options[0] = extraBackground; 64 | 65 | useEffect(() => { 66 | if (selectedBackground !== 0) setExtraBackground(null); 67 | }, [selectedBackground]); 68 | 69 | if (!data) return null; 70 | return ( 71 | <> 72 | {showBoardModal ? ( 73 | 80 | ) : null} 81 |
82 |
83 |
87 | { 90 | setTitle(e.target.value); 91 | }} 92 | className="addboard-modal__title" 93 | placeholder="Add board title" 94 | /> 95 | 101 |
102 | {title.trim() === "" ? ( 103 | 109 | ) : ( 110 | 116 | )} 117 |
118 | 119 |
120 | {options.map((option, index) => ( 121 | 136 | ))} 137 | 143 |
144 |
145 | 146 | ); 147 | }; 148 | 149 | export default AddBoardModal; 150 | -------------------------------------------------------------------------------- /frontend/src/static/css/components/_board.scss: -------------------------------------------------------------------------------- 1 | .board { 2 | background-color: $grey; 3 | height: 100vh; 4 | width: 100vw; 5 | overflow: hidden; 6 | padding: 100px 50px 40px; 7 | display: flex; 8 | flex-direction: column; // So we can make up lists take up remaining space 9 | 10 | &__title, 11 | &__title-edit { 12 | font-weight: 500; 13 | font-size: 1.25rem; 14 | height: 25px; 15 | } 16 | 17 | &__title { 18 | cursor: pointer; 19 | } 20 | 21 | &__title-edit { 22 | padding: 0.25em; 23 | border: 1px darken($border-color, 40%) solid; 24 | 25 | position: relative; 26 | top: -3px; 27 | left: -6px; 28 | 29 | &:focus { 30 | border: 1px $blue solid; 31 | } 32 | } 33 | 34 | &__subtitle { 35 | font-size: 0.875rem; 36 | color: $secondary-text; 37 | margin-bottom: 1.5em; 38 | } 39 | 40 | &__lists { 41 | flex: 1; 42 | display: flex; 43 | width: 100%; 44 | overflow-x: auto; 45 | 46 | & > .list { 47 | margin: 0.5em 1em 0; 48 | 49 | &:first-of-type { 50 | margin-left: 0; 51 | } 52 | 53 | &:last-of-type { 54 | margin-right: 0; 55 | } 56 | } 57 | } 58 | 59 | &__create-list { 60 | height: fit-content; 61 | width: 300px; 62 | min-width: 300px; 63 | margin-top: 0.5em; 64 | margin-left: 1em; 65 | 66 | i { 67 | margin-right: 0.5em; 68 | } 69 | } 70 | 71 | &__create-list-form { 72 | width: 300px; 73 | min-width: 300px; 74 | padding: 1em; 75 | margin-top: 0.5em; 76 | margin-left: 1em; 77 | background-color: white; 78 | height: fit-content; 79 | border-radius: 5px; 80 | box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.1); 81 | 82 | & input { 83 | width: 100%; 84 | padding: 0.5em; 85 | display: block; 86 | margin-bottom: 0.5em; 87 | border: 1px darken($border-color, 40%) solid; 88 | 89 | &:focus { 90 | border: 1px $blue solid; 91 | } 92 | } 93 | } 94 | } 95 | 96 | .list { 97 | background-color: white; 98 | padding: 2em 1em 1em 1em; 99 | width: fit-content; // Use card width 100 | height: fit-content; 101 | max-height: 97%; 102 | border: 0; 103 | border-radius: 5px; 104 | box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.1); 105 | display: flex; // So we can use margin-top: auto on add-card 106 | flex-direction: column; 107 | 108 | &__title { 109 | font-weight: 500; 110 | font-size: 1.25rem; 111 | display: flex; 112 | justify-content: space-between; 113 | align-items: center; 114 | 115 | p { 116 | height: 25px; 117 | } 118 | 119 | & > .far { 120 | color: $secondary-text; 121 | } 122 | } 123 | 124 | &__title-edit { 125 | width: 100%; 126 | margin-right: 1em; 127 | font-weight: 500; 128 | font-size: 1.25rem; 129 | padding: 0.25em; 130 | border: 1px darken($border-color, 40%) solid; 131 | height: 25px; 132 | 133 | position: relative; 134 | top: -3px; 135 | left: -6px; 136 | 137 | &:focus { 138 | border: 1px $blue solid; 139 | } 140 | } 141 | 142 | &__cards { 143 | margin: 1.125em 0 1em; 144 | max-height: calc(100% - 110px); 145 | min-height: calc(100% - 110px); 146 | overflow-y: auto; 147 | 148 | & > .card { 149 | margin: 1em 0; 150 | 151 | &:first-of-type { 152 | margin-top: 0; 153 | } 154 | 155 | &:last-of-type { 156 | margin-bottom: 0; 157 | } 158 | } 159 | } 160 | 161 | &__add-card { 162 | text-align: center; 163 | font-size: 0.875rem; 164 | width: 300px; 165 | padding: 0.75em 0; 166 | color: $secondary-text; 167 | 168 | &--active { 169 | color: white; 170 | } 171 | } 172 | 173 | &__add-card-form { 174 | margin-top: 1em; 175 | 176 | input { 177 | width: 100%; 178 | border: 1px solid rgba($border-color, 0.55); 179 | border-radius: 5px; 180 | padding: 0.75em; 181 | } 182 | } 183 | } 184 | 185 | .card { 186 | background: white; 187 | border: 1px rgba($border-color, 0.55) solid; 188 | border-radius: 5px; 189 | padding: 0.75em; 190 | width: 300px; 191 | position: relative; 192 | cursor: pointer; 193 | 194 | &__pen { 195 | position: absolute; 196 | top: 3px; 197 | right: 3px; 198 | font-size: 0.75rem; 199 | color: $secondary-text; 200 | padding: 0.5em; 201 | border-radius: 5px; 202 | display: none; 203 | 204 | &:hover { 205 | background: darken(white, 8%); 206 | color: darken($secondary-text, 16%); 207 | } 208 | } 209 | 210 | &:hover { 211 | background: darken(white, 2%); 212 | 213 | .card__pen { 214 | display: block; 215 | } 216 | } 217 | 218 | &__title { 219 | margin-top: 0.25em; 220 | } 221 | 222 | &__subtitle { 223 | margin-top: 0.75em; 224 | font-size: 0.75rem; 225 | color: $secondary-text; 226 | } 227 | 228 | &__members { 229 | display: flex; 230 | justify-content: flex-end; 231 | margin-top: 1.5em; 232 | } 233 | 234 | &__image, 235 | &__color { 236 | width: calc(100% + 1.5em); // + Twice parent's padding 237 | height: 65px; 238 | margin-left: -0.75em; 239 | margin-top: -0.75em; 240 | margin-bottom: 0.35em; // Too close to labels otherwise 241 | } 242 | 243 | &__image { 244 | height: 125px; 245 | img { 246 | border-radius: 5px 5px 0 0; 247 | width: 100%; 248 | height: 100%; 249 | object-fit: cover; 250 | } 251 | } 252 | 253 | &--image { 254 | .card__title { 255 | margin-top: 1em; 256 | } 257 | 258 | &:hover { 259 | .card__pen { 260 | background: darken(white, 2%); 261 | } 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /backend/boards/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.fields import GenericForeignKey 2 | from django.contrib.contenttypes.models import ContentType 3 | from django.db import models 4 | from django.db.models import Max 5 | from django.utils import timezone 6 | from users.models import User 7 | 8 | 9 | class Board(models.Model): 10 | owner_model = models.ForeignKey(ContentType, blank=False, null=False, 11 | related_name='board', 12 | on_delete=models.CASCADE, 13 | limit_choices_to=models.Q(app_label='users', model='user') | models.Q(app_label='projects', model='project')) 14 | owner_id = models.PositiveIntegerField(null=False, blank=False) 15 | owner = GenericForeignKey('owner_model', 'owner_id') 16 | 17 | title = models.CharField(max_length=255, blank=False, null=False) 18 | description = models.TextField(blank=True, null=False) 19 | 20 | # Only one of the below will be used from the frontend 21 | image = models.ImageField(blank=True, upload_to='board_images') 22 | image_url = models.URLField(blank=True, null=False) 23 | color = models.CharField(blank=True, null=False, max_length=6) # Hex Code 24 | 25 | created_at = models.DateTimeField(default=timezone.now) 26 | 27 | def __str__(self): 28 | return self.title 29 | 30 | 31 | class List(models.Model): 32 | board = models.ForeignKey( 33 | Board, on_delete=models.CASCADE, related_name="lists") 34 | title = models.CharField(max_length=255, blank=False, null=False) 35 | order = models.DecimalField(max_digits=30, decimal_places=15 , blank=True, null=True) 36 | created_at = models.DateTimeField(default=timezone.now) 37 | 38 | def __str__(self): 39 | return self.title 40 | 41 | def save(self, *args, **kwargs): 42 | filtered_objects = List.objects.filter(board=self.board) 43 | if not self.order and filtered_objects.count() == 0: 44 | self.order = 2 ** 16 - 1 45 | elif not self.order: 46 | self.order = filtered_objects.aggregate(Max('order'))[ 47 | 'order__max'] + 2 ** 16 - 1 48 | return super().save(*args, **kwargs) 49 | 50 | 51 | class Label(models.Model): 52 | board = models.ForeignKey( 53 | Board, on_delete=models.CASCADE, related_name='labels') 54 | title = models.CharField(max_length=255, blank=True, null=False) 55 | color = models.CharField(max_length=255, blank=False, null=False) 56 | 57 | def __str__(self): 58 | return self.title 59 | 60 | 61 | class Item(models.Model): 62 | list = models.ForeignKey( 63 | List, on_delete=models.CASCADE, related_name='items') 64 | title = models.CharField(max_length=255, blank=False, null=False) 65 | description = models.TextField(blank=True, null=False) 66 | 67 | # Only one of the below will be used from the frontend 68 | image = models.ImageField(blank=True, upload_to='item_images') 69 | image_url = models.URLField(blank=True, null=False) 70 | color = models.CharField(blank=True, null=False, max_length=6) # Hex Code 71 | 72 | order = models.DecimalField(max_digits=30,decimal_places=15, blank=True, null=True) 73 | labels = models.ManyToManyField(Label, blank=True) 74 | assigned_to = models.ManyToManyField(User, blank=True) 75 | due_date = models.DateTimeField(blank=True, null=True) 76 | created_at = models.DateTimeField(default=timezone.now) 77 | 78 | def __str__(self): 79 | return self.title 80 | 81 | def save(self, *args, **kwargs): 82 | filtered_objects = Item.objects.filter(list=self.list) 83 | if not self.order and filtered_objects.count() == 0: 84 | self.order = 2 ** 16 - 1 85 | elif not self.order: 86 | self.order = filtered_objects.aggregate(Max('order'))[ 87 | 'order__max'] + 2 ** 16 - 1 88 | return super().save(*args, **kwargs) 89 | 90 | 91 | class Comment(models.Model): 92 | author = models.ForeignKey( 93 | User, on_delete=models.CASCADE, related_name='comments') 94 | item = models.ForeignKey( 95 | Item, on_delete=models.CASCADE, related_name='comments') 96 | body = models.TextField(blank=False, null=False) 97 | created_at = models.DateTimeField(default=timezone.now) 98 | 99 | def __str__(self): 100 | return f'{self.body[:50]}{"..." if len(self.body) > 50 else ""}' 101 | 102 | 103 | class Attachment(models.Model): 104 | item = models.ForeignKey( 105 | Item, on_delete=models.CASCADE, related_name='attachments') 106 | upload = models.FileField(upload_to='attachments') 107 | 108 | 109 | # https://help.trello.com/article/793-receiving-trello-notifications 110 | extra_word_dict = {'commented': 'on'} 111 | 112 | 113 | class Notification(models.Model): 114 | actor = models.ForeignKey( 115 | User, on_delete=models.CASCADE, related_name='actions') 116 | recipient = models.ForeignKey( 117 | User, on_delete=models.CASCADE, related_name='notifications') 118 | verb = models.CharField(max_length=255, blank=False, null=False) 119 | unread = models.BooleanField(default=True, blank=False, db_index=True) 120 | 121 | created_at = models.DateTimeField(default=timezone.now) 122 | 123 | # Optional, opened 124 | target_model = models.ForeignKey(ContentType, blank=True, null=True, 125 | related_name='target_obj', 126 | on_delete=models.CASCADE) 127 | target_id = models.PositiveIntegerField(null=True, blank=True) 128 | target = GenericForeignKey('target_model', 'target_id') 129 | 130 | action_object_model = models.ForeignKey(ContentType, blank=True, null=True, 131 | related_name='action_object_obj', 132 | on_delete=models.CASCADE) 133 | action_object_id = models.PositiveIntegerField(null=True, blank=True) 134 | action_object = GenericForeignKey( 135 | 'action_object_model', 'action_object_id') 136 | 137 | def __str__(self): 138 | if self.target: 139 | if self.action_object: 140 | return f'{self.actor.full_name} {self.verb} {self.action_object} {extra_word_dict[self.verb]} {self.target}' 141 | else: 142 | return f'{self.actor.full_name} {self.verb} {self.target}' 143 | else: 144 | return f'{self.actor.full_name} {self.verb}' 145 | 146 | """ 147 | commented on , you were assigned to this item 148 | assigned you to 149 | invited you to 150 | made you admin of 151 | """ 152 | --------------------------------------------------------------------------------