├── 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 |
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 | You need to enable JavaScript to run this app.
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 |
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 |
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 |
29 | {!board.is_starred ? (
30 |
31 | ) : (
32 |
33 | )}
34 |
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 | 
64 | 
65 | 
66 | 
67 | 
68 | 
69 | 
70 | 
71 | 
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 |
setShowModal(false)}>
48 |
49 |
50 |
51 |
52 |
53 |
54 | Enter Email Address or Username
55 |
56 |
setMembers(e.target.value)}
63 | />
64 | {members.trim() !== "" ? (
65 |
66 | Invite to Project
67 |
68 | ) : (
69 |
70 | Invite to Project
71 |
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 |
setShowModal(false)}>
51 |
52 |
53 |
54 |
55 |
56 | {member.access_level === 2 ? (
57 |
58 | Admin
59 |
60 |
61 | ) : (
62 |
changePermission(2)}
65 | >
66 | Admin
67 |
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 |
changePermission(1)}
85 | >
86 | Normal
87 |
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 |
setShowBoardModal(false)}>
29 |
30 |
31 |
32 |
33 |
Photos
34 |
35 | {images.slice(0, 6).map((imageOption) => (
36 |
44 | {
47 | setExtraBackground(imageOption);
48 | setSelectedBackground(0);
49 | }}
50 | >
51 | {extraBackground?.[0] === imageOption?.[0] ? (
52 |
53 | ) : null}
54 |
55 |
56 | ))}
57 |
58 |
59 |
60 |
Colors
61 |
62 | {colors.slice(0, 6).map((colorOption) => (
63 |
71 | {
74 | setExtraBackground(colorOption);
75 | setSelectedBackground(0);
76 | }}
77 | >
78 | {extraBackground?.[0] === colorOption[0] ? (
79 |
80 | ) : null}
81 |
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 |
92 |
93 |
94 |
setShowModal(false)}>
95 |
96 |
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 | setIsTabOne(true)}
116 | style={{ marginRight: "1em" }}
117 | >
118 | Prev
119 |
120 | ) : null}
121 | setIsTabOne(false)}
125 | disabled={!validTabContent()}
126 | >
127 | {isTabOne ? "Next" : "Sign Up"}
128 |
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 |
setShowModal(false)}>
52 |
53 |
54 |
55 |
56 |
57 | setShowAll((showAll) => !showAll)}>
58 | {!showAll ? "View All" : "Filter by Unread"}
59 |
60 | {!showAll && notificationsToShow.length !== 0 && (
61 | Mark all read
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 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | Hello, {authUser.full_name.replace(/ .*/, "")}
90 |
91 |
92 | setShowNotifications(true)}>
93 |
94 |
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 | setShowAddBoardModal(false)}
98 | >
99 |
100 |
101 |
102 | {title.trim() === "" ? (
103 |
107 | Create Board
108 |
109 | ) : (
110 |
114 | Create Board
115 |
116 | )}
117 |
118 |
119 |
120 | {options.map((option, index) => (
121 | {
123 | setSelectedBackground(index);
124 | }}
125 | className={`addboard-modal__color-box${
126 | option[1] ? " color-box--img" : ""
127 | }`}
128 | style={getAddBoardStyle(...option)}
129 | key={uuidv4()}
130 | >
131 | {" "}
132 | {selectedBackground == index && (
133 |
134 | )}{" "}
135 |
136 | ))}
137 | setShowBoardModal(true)}
140 | >
141 |
142 |
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 |
--------------------------------------------------------------------------------