├── .env.example ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .prettierignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── components.json ├── cypress.config.ts ├── cypress ├── e2e │ ├── login.cy.ts │ └── registration.cy.ts ├── fixtures │ └── example.json └── support │ ├── commands.ts │ └── e2e.ts ├── docker-compose.yml ├── docs └── images │ └── screenshot-1.png ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── prisma ├── migrations │ ├── 20230828122827_add_boards_table │ │ └── migration.sql │ ├── 20230828123239_rename_user_id_to_owner_id │ │ └── migration.sql │ ├── 20230828143722_add_unique_constraint_to_the_slug_and_ownerid_columns │ │ └── migration.sql │ ├── 20230830160003_add_starredboards_pivot │ │ └── migration.sql │ ├── 20230902154755_rename_the_image_column_to_background │ │ └── migration.sql │ ├── 20231008153940_fix_table_names │ │ └── migration.sql │ └── migration_lock.toml ├── schema.prisma └── seed.ts ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── (auth) │ │ ├── login │ │ │ ├── discord-auth.tsx │ │ │ ├── github-auth.tsx │ │ │ ├── login-form.tsx │ │ │ └── page.tsx │ │ └── register │ │ │ ├── actions.ts │ │ │ ├── page.tsx │ │ │ └── registration-form.tsx │ ├── (dashboard) │ │ ├── [username] │ │ │ └── [boardSlug] │ │ │ │ ├── @card │ │ │ │ ├── (.)cards │ │ │ │ │ └── [cardId] │ │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── activity │ │ │ │ │ │ │ └── activity.tsx │ │ │ │ │ │ ├── description │ │ │ │ │ │ │ ├── card-description.tsx │ │ │ │ │ │ │ └── description-wysiwyg.tsx │ │ │ │ │ │ ├── dialog-wrapper.tsx │ │ │ │ │ │ ├── header │ │ │ │ │ │ │ └── card-header.tsx │ │ │ │ │ │ └── labels │ │ │ │ │ │ │ ├── label-form.tsx │ │ │ │ │ │ │ ├── label-list.tsx │ │ │ │ │ │ │ └── labels.tsx │ │ │ │ │ │ ├── actions.ts │ │ │ │ │ │ ├── error.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ └── default.tsx │ │ │ │ ├── _components │ │ │ │ ├── background.tsx │ │ │ │ ├── board.tsx │ │ │ │ ├── card │ │ │ │ │ ├── card-item.tsx │ │ │ │ │ ├── card-skeleton.tsx │ │ │ │ │ ├── cards-area.tsx │ │ │ │ │ └── new-card.tsx │ │ │ │ ├── header │ │ │ │ │ ├── board-header.tsx │ │ │ │ │ ├── fullscreen-button.tsx │ │ │ │ │ └── member-list.tsx │ │ │ │ └── list │ │ │ │ │ ├── list-item.tsx │ │ │ │ │ ├── list-list.tsx │ │ │ │ │ └── new-list.tsx │ │ │ │ ├── actions.tsx │ │ │ │ ├── cards │ │ │ │ └── [cardId] │ │ │ │ │ └── page.tsx │ │ │ │ ├── error.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ ├── _components │ │ │ ├── boards │ │ │ │ ├── board-list.tsx │ │ │ │ └── boards.tsx │ │ │ └── profile │ │ │ │ ├── edit-profile.tsx │ │ │ │ ├── profile-details.tsx │ │ │ │ └── profile.tsx │ │ ├── actions.ts │ │ ├── layout.tsx │ │ ├── new │ │ │ ├── _components │ │ │ │ ├── background │ │ │ │ │ ├── background-select.tsx │ │ │ │ │ ├── color-picker.tsx │ │ │ │ │ └── image-picker.tsx │ │ │ │ └── new-form.tsx │ │ │ ├── actions.ts │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── settings │ │ │ ├── _components │ │ │ └── header.tsx │ │ │ ├── actions.ts │ │ │ ├── layout.tsx │ │ │ └── password │ │ │ └── page.tsx │ ├── api │ │ └── auth │ │ │ └── [...nextauth] │ │ │ ├── actions.ts │ │ │ ├── options.ts │ │ │ └── route.ts │ ├── apple-icon.png │ ├── favicon.ico │ ├── icon.png │ ├── layout.tsx │ └── not-found.tsx ├── assets │ ├── 404.svg │ ├── board.svg │ ├── logo.svg │ └── star.svg ├── components │ ├── boards │ │ ├── board-item.tsx │ │ └── board-menu-item.tsx │ ├── color-selector.tsx │ ├── editable-title.tsx │ ├── navbar │ │ ├── actions.ts │ │ ├── create-button.tsx │ │ ├── dropdowns │ │ │ ├── boards-dropdown.tsx │ │ │ └── theme-toggle.tsx │ │ ├── navbar.tsx │ │ └── user-actions.tsx │ ├── navigate.tsx │ ├── not-found.tsx │ ├── theme-provider.tsx │ ├── ui │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── loading-button.tsx │ │ ├── scroll-area.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle.tsx │ │ └── tooltip.tsx │ └── wysiwyg │ │ └── rich-text-editor.tsx ├── config │ └── site.ts ├── context │ ├── auth-context.tsx │ ├── board-context.tsx │ ├── board-list-context.tsx │ ├── card-context.tsx │ └── fullscreen-context.tsx ├── env.mjs ├── hooks │ ├── use-outer-click.ts │ └── use-ripple.tsx ├── lib │ ├── fonts.ts │ ├── prisma.ts │ ├── schemas │ │ ├── board.ts │ │ ├── card.ts │ │ ├── label.ts │ │ ├── list.ts │ │ ├── login.ts │ │ ├── password.ts │ │ ├── profile.ts │ │ └── register.ts │ └── utils.ts ├── middleware.ts ├── styles │ └── globals.css └── types │ ├── board.ts │ └── next-auth.d.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="mysql://root:password@localhost:3306/trello_clone?schema=public" 2 | 3 | NEXTAUTH_SECRET=SECRET 4 | NEXTAUTH_URL=http://localhost:3000 5 | APP_URL=http://localhost:3000 6 | 7 | # OPTIONAL 8 | GITHUB_SECRET= 9 | GITHUB_ID= 10 | 11 | DISCORD_ID= 12 | DISCORD_SECRET= 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | 29 | **Please choose an option below** 30 | 31 | - [ ] I would like to work on this issue. 32 | - [ ] I just opened the issue and would like someone else to handle it. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: enhancement 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | 21 | **Please choose an option below** 22 | 23 | - [ ] I would like to work on this issue. 24 | - [ ] I just opened the issue and would like someone else to handle it. 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please include a summary of the change and which issue is fixed. 4 | 5 | ## Type of change 6 | 7 | Please delete options that are not relevant. 8 | 9 | - [ ] Bug fix (non-breaking change which fixes an issue) 10 | - [ ] New feature (non-breaking change which adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] This change requires a documentation update 13 | 14 | ## Checklist: 15 | 16 | - [ ] My code follows the style and structure of this project 17 | - [ ] My code follows the principles of clean code 18 | - [ ] My changes generate no new warnings, bugs or errors 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | /data 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | # ide 40 | .idea/ 41 | .vscode/ 42 | .vs/ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx,md,json,css}": ["npm run format"] 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | dist 3 | node_modules 4 | build -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at {{ email }}. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, thank you for taking the time to contribute! Every contribution is appreciated. 4 | Reading and following these guidelines will help us make the contribution process easy and effective for everyone involved. 5 | 6 | - [Contributing](#contributing) 7 | - [Code of Conduct](#code-of-conduct) 8 | - [Ways of contributing](#ways-of-contributing) 9 | - [Issues](#issues) 10 | - [Found a bug?](#found-a-bug) 11 | - [Feature Request, Suggestion](#feature-request-suggestion) 12 | - [Pull Request](#pull-request) 13 | - [Other ways](#other-ways) 14 | - [General information](#general-information) 15 | 16 |
17 | 18 | ## Code of Conduct 19 | 20 | By participating and contributing to this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). 21 | 22 |
23 | 24 | ## Ways of contributing 25 | 26 | Contributions to this repository are made via **Issues** and **Pull Requests**. 27 | 28 | ### Issues 29 | 30 | #### Found a bug? 31 | 32 | Open a new issue describing the bug and how it occurred. 33 | Please use an issue template, it will guide you on how to write your issue. 34 | 35 | #### Feature Request, Suggestion 36 | 37 | Again, you should use one of our issue templates. 38 | If you want to work on your suggestion as well, please open an issue before you start working on it, so we know about it. 39 | 40 | ### Pull Request 41 | 42 | If there's an issue you want to fix or develop, or if you've submitted an issue as a feature request and want to work on the solution: 43 | 44 | 1. Write a comment under the issue that you would like to work on it, so I can assign you the issue 45 | 2. Fork this repository 46 | 3. Clone your fork 47 | 4. Start working on your changes 48 | 5. Commit and push your changes 49 | 6. Open a Pull Request with your changes 50 | 51 | ### Other ways 52 | 53 | If you don't have any ideas yet, but want to support the project: 54 | 55 | 1. Add a GitHub star to this project 56 | 2. Tweet about the project 57 | 3. Write a review or blog post about the project 58 | 59 |
60 | 61 | ## General information 62 | 63 | - Please use a [pull request template](./.github/PULL_REQUEST_TEMPLATE.md) when you open a PR. 64 | - Please, when writing your commit messages, use [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Olivér Mrakovics 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trello Clone 2 | 3 | An open source Trello Clone built with everything new in the [Next.js 14](https://github.com/vercel/next.js) ecosystem. 4 | 5 | Screenshot 6 | 7 |
8 | 9 | ## Tech Stack 10 | 11 | - Language: [TypeScript](https://www.typescriptlang.org/) 12 | - Framework: [React](https://react.dev/), [Next.js](https://nextjs.org/) 13 | - Styles: [TailwindCSS](https://tailwindcss.com/) 14 | - UI Components: [shadcn/ui](https://ui.shadcn.com/) 15 | - ORM: [Prisma](https://www.prisma.io/) 16 | - Authentication: [NextAuth](https://next-auth.js.org/) 17 | - Testing: [Cypress](https://www.cypress.io/) 18 | - Validation: [zod](https://zod.dev/) 19 | - Forms: [react-hook-form](https://react-hook-form.com/) 20 | - WYSIWYG: [Tiptap](https://tiptap.dev/) 21 | - Primitives: [RadixUI](https://www.radix-ui.com/) 22 | - Icons: [Lucide](https://lucide.dev/icons/) 23 | - DND: [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd) 24 | - Formatting: [Prettier](https://prettier.io/) 25 | - Background Images: [Unsplash](https://unsplash.com/) 26 | - Illustrations: [storyset](https://storyset.com/) 27 | 28 | ## Running Locally 29 | 30 | ```sh 31 | # Clone the project: 32 | git clone https://github.com/0l1v3rr/trello-clone.git 33 | cd trello-clone 34 | 35 | # Copy the .env.example file and rename it to .env 36 | # Also, make the appropriate changes 37 | cp .env.example .env 38 | 39 | # Run the database with docker: 40 | docker compose up -d 41 | 42 | # Install the dependencies: 43 | npm i 44 | npm run prepare 45 | 46 | # Reset the DB 47 | npx prisma migrate reset 48 | 49 | # Run the application 50 | npm run dev 51 | 52 | # Also, you can preview the app to see how it would work in production 53 | npm run preview 54 | ``` 55 | 56 | After the successful installation, the app should start [here](http://localhost:3000). You can log in with the following credentials: username: **test**, password: **test** 57 | 58 | ## Contributing 59 | 60 | **Contributions are more than welcome!** Please open an issue if you have any questions or suggestions. See the [contributing guide](./CONTRIBUTING.md) for more information. 61 | 62 | ## License 63 | 64 | This project is licensed under the [MIT License](./LICENSE). 65 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.x.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Please report (suspected) security vulnerabilities via **[email](mailto:oliver.mrakovics@gmail.com)**. You will receive a response from us within 48 hours. If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days. 12 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: "http://localhost:3000", 6 | setupNodeEvents(on, config) { 7 | // implement node event listeners here 8 | }, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /cypress/e2e/login.cy.ts: -------------------------------------------------------------------------------- 1 | describe("login page tests", () => { 2 | beforeEach(() => { 3 | cy.visit("/login"); 4 | }); 5 | 6 | it("should log in a user", () => { 7 | cy.get('[data-test-id="login-email"]').type("test"); 8 | cy.get('[data-test-id="login-password"').type("test"); 9 | cy.get('[data-test-id="login-submit-btn"]').click(); 10 | cy.location("pathname").should("eq", "/"); 11 | 12 | cy.visit("/login"); 13 | 14 | cy.get('[data-test-id="login-email"]').type("test@gmail.com"); 15 | cy.get('[data-test-id="login-password"').type("test"); 16 | cy.get('[data-test-id="login-submit-btn"]').click(); 17 | cy.location("pathname").should("eq", "/"); 18 | }); 19 | 20 | it("should not log in a user because of wrong credentials", () => { 21 | cy.contains(/Invalid login credentials/i).should("not.exist"); 22 | cy.get('[data-test-id="login-submit-btn"]').click(); 23 | cy.contains(/Invalid login credentials/i); 24 | 25 | cy.reload(); 26 | 27 | cy.contains(/Invalid login credentials/i).should("not.exist"); 28 | cy.get('[data-test-id="login-email"]').type("doesntexist@aaa.com"); 29 | cy.get('[data-test-id="login-password"').type( 30 | "ThisIsAnIncorrectPasswordComeOn!" 31 | ); 32 | cy.get('[data-test-id="login-submit-btn"]').click(); 33 | cy.contains(/Invalid login credentials/i); 34 | }); 35 | 36 | it("should redirect to the register page", () => { 37 | cy.get('[data-test-id="reg-btn"]').click(); 38 | cy.location("pathname").should("eq", "/register"); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /cypress/e2e/registration.cy.ts: -------------------------------------------------------------------------------- 1 | describe("registration page tests", () => { 2 | beforeEach(() => { 3 | cy.visit("/register"); 4 | }); 5 | 6 | it("should create a user", () => { 7 | cy.get('[data-test-id="reg-name"]').type("John Doe"); 8 | cy.get('[data-test-id="reg-email"]').type("johndoe@test.com"); 9 | cy.get('[data-test-id="reg-password"]').type("TestPass1@3"); 10 | cy.get('[data-test-id="reg-password-confirm"]').type("TestPass1@3"); 11 | cy.get('[data-test-id="reg-submit-btn"]').click(); 12 | cy.location("pathname").should("eq", "/login"); 13 | }); 14 | 15 | it("should not create a user because the given email already exist", () => { 16 | cy.contains(/User with this email already exists/i).should("not.exist"); 17 | cy.get('[data-test-id="reg-name"]').type("Test User"); 18 | cy.get('[data-test-id="reg-email"]').type("test@gmail.com"); 19 | cy.get('[data-test-id="reg-password"]').type("TestPass1@3"); 20 | cy.get('[data-test-id="reg-password-confirm"]').type("TestPass1@3"); 21 | cy.get('[data-test-id="reg-submit-btn"]').click(); 22 | cy.contains(/User with this email already exists/i); 23 | }); 24 | 25 | it("should redirect to the login page", () => { 26 | cy.get('[data-test-id="login-btn"]').click(); 27 | cy.location("pathname").should("eq", "/login"); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } 38 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | db: 4 | image: mysql:8.1 5 | environment: 6 | MYSQL_DATABASE: 'trello_clone' 7 | MYSQL_PASSWORD: 'password' 8 | MYSQL_ROOT_PASSWORD: 'password' 9 | ports: 10 | - '3306:3306' 11 | expose: 12 | - '3306' 13 | volumes: 14 | - ./data:/var/lib/mysql 15 | -------------------------------------------------------------------------------- /docs/images/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/0l1v3rr/trello-clone/df221aac8259a51c6cfd491606e6961a9358f7d9/docs/images/screenshot-1.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | serverComponentsExternalPackages: ["@prisma/client", "bcrypt"], 5 | }, 6 | images: { 7 | remotePatterns: [{ protocol: "https", hostname: "**.unsplash.com" }], 8 | }, 9 | }; 10 | 11 | module.exports = nextConfig; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trello-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "lint:fix": "next lint --fix", 11 | "preview": "next build && next start", 12 | "format": "prettier --write \"**/*.{ts,tsx,js,json,md,css}\" --cache", 13 | "format:check": "prettier --check \"**/*.{ts,tsx,js,json,md,css}\" --cache", 14 | "prepare": "husky install" 15 | }, 16 | "prisma": { 17 | "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" 18 | }, 19 | "dependencies": { 20 | "@hookform/resolvers": "^3.3.2", 21 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 22 | "@prisma/client": "^5.7.1", 23 | "@radix-ui/react-avatar": "^1.0.4", 24 | "@radix-ui/react-checkbox": "^1.0.4", 25 | "@radix-ui/react-dialog": "^1.0.5", 26 | "@radix-ui/react-dropdown-menu": "^2.0.6", 27 | "@radix-ui/react-hover-card": "^1.0.7", 28 | "@radix-ui/react-label": "^2.0.2", 29 | "@radix-ui/react-scroll-area": "^1.0.5", 30 | "@radix-ui/react-separator": "^1.0.3", 31 | "@radix-ui/react-slot": "^1.0.2", 32 | "@radix-ui/react-switch": "^1.0.3", 33 | "@radix-ui/react-tabs": "^1.0.4", 34 | "@radix-ui/react-toggle": "^1.0.3", 35 | "@radix-ui/react-tooltip": "^1.0.7", 36 | "@t3-oss/env-nextjs": "^0.7.1", 37 | "@tiptap/react": "^2.1.13", 38 | "@tiptap/starter-kit": "^2.1.13", 39 | "@types/node": "20.10.5", 40 | "@types/react": "18.2.45", 41 | "@types/react-dom": "18.2.18", 42 | "autoprefixer": "10.4.16", 43 | "bcrypt": "^5.1.1", 44 | "class-variance-authority": "^0.7.0", 45 | "clsx": "^2.0.0", 46 | "cypress": "^13.6.1", 47 | "eslint": "8.56.0", 48 | "eslint-config-next": "^14.1.0", 49 | "html-react-parser": "^5.0.11", 50 | "lucide-react": "^0.298.0", 51 | "next": "^14.1.0", 52 | "next-auth": "^4.24.5", 53 | "next-themes": "^0.2.1", 54 | "postcss": "8.4.32", 55 | "react": "^18.2.0", 56 | "react-beautiful-dnd": "^13.1.1", 57 | "react-dom": "^18.2.0", 58 | "react-hook-form": "^7.49.2", 59 | "react-icons": "^4.12.0", 60 | "tailwind-merge": "^2.1.0", 61 | "tailwindcss": "3.4.0", 62 | "tailwindcss-animate": "^1.0.7", 63 | "ts-node": "^10.9.2", 64 | "typescript": "5.3.3", 65 | "zod": "^3.22.4" 66 | }, 67 | "devDependencies": { 68 | "@types/bcrypt": "^5.0.2", 69 | "@types/react-beautiful-dnd": "^13.1.7", 70 | "husky": "^8.0.0", 71 | "lint-staged": "^15.2.0", 72 | "prettier": "^3.1.1", 73 | "prettier-plugin-tailwindcss": "^0.5.9", 74 | "prisma": "^5.7.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config} */ 2 | module.exports = { 3 | endOfLine: "lf", 4 | semi: true, 5 | singleQuote: false, 6 | tabWidth: 2, 7 | trailingComma: "es5", 8 | importOrder: [ 9 | "^(react/(.*)$)|^(react$)", 10 | "^(next/(.*)$)|^(next$)", 11 | "", 12 | "^types$", 13 | "^@/types/(.*)$", 14 | "^@/config/(.*)$", 15 | "^@/lib/(.*)$", 16 | "^@/hooks/(.*)$", 17 | "^@/components/ui/(.*)$", 18 | "^@/components/(.*)$", 19 | "^@/styles/(.*)$", 20 | "^@/app/(.*)$", 21 | "^[./]", 22 | ], 23 | plugins: [ 24 | "@ianvs/prettier-plugin-sort-imports", 25 | "prettier-plugin-tailwindcss", 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /prisma/migrations/20230828122827_add_boards_table/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `user` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- CreateTable 8 | CREATE TABLE `users` ( 9 | `id` VARCHAR(191) NOT NULL, 10 | `name` VARCHAR(191) NOT NULL, 11 | `email` VARCHAR(191) NOT NULL, 12 | `username` VARCHAR(191) NOT NULL, 13 | `password` VARCHAR(191) NULL, 14 | `status` VARCHAR(191) NULL, 15 | `image` VARCHAR(191) NULL, 16 | `active` BOOLEAN NULL, 17 | `role` ENUM('USER', 'ADMIN') NOT NULL DEFAULT 'USER', 18 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 19 | `updatedAt` DATETIME(3) NOT NULL, 20 | 21 | UNIQUE INDEX `users_email_key`(`email`), 22 | UNIQUE INDEX `users_username_key`(`username`), 23 | PRIMARY KEY (`id`) 24 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 25 | 26 | -- CreateTable 27 | CREATE TABLE `boards` ( 28 | `id` VARCHAR(191) NOT NULL, 29 | `name` VARCHAR(191) NOT NULL, 30 | `slug` VARCHAR(191) NOT NULL, 31 | `image` JSON NOT NULL, 32 | `public` BOOLEAN NOT NULL, 33 | `userId` VARCHAR(191) NOT NULL, 34 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 35 | `updatedAt` DATETIME(3) NOT NULL, 36 | 37 | PRIMARY KEY (`id`) 38 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 39 | 40 | -- CreateTable 41 | CREATE TABLE `_GuestBoards` ( 42 | `A` VARCHAR(191) NOT NULL, 43 | `B` VARCHAR(191) NOT NULL, 44 | 45 | UNIQUE INDEX `_GuestBoards_AB_unique`(`A`, `B`), 46 | INDEX `_GuestBoards_B_index`(`B`) 47 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 48 | 49 | -- AddForeignKey 50 | ALTER TABLE `boards` ADD CONSTRAINT `boards_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 51 | 52 | -- AddForeignKey 53 | ALTER TABLE `_GuestBoards` ADD CONSTRAINT `_GuestBoards_A_fkey` FOREIGN KEY (`A`) REFERENCES `boards`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 54 | 55 | -- AddForeignKey 56 | ALTER TABLE `_GuestBoards` ADD CONSTRAINT `_GuestBoards_B_fkey` FOREIGN KEY (`B`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 57 | -------------------------------------------------------------------------------- /prisma/migrations/20230828123239_rename_user_id_to_owner_id/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `userId` on the `boards` table. All the data in the column will be lost. 5 | - Added the required column `ownerId` to the `boards` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- DropForeignKey 9 | ALTER TABLE `boards` DROP FOREIGN KEY `boards_userId_fkey`; 10 | 11 | -- AlterTable 12 | ALTER TABLE `boards` DROP COLUMN `userId`, 13 | ADD COLUMN `ownerId` VARCHAR(191) NOT NULL; 14 | 15 | -- AddForeignKey 16 | ALTER TABLE `boards` ADD CONSTRAINT `boards_ownerId_fkey` FOREIGN KEY (`ownerId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 17 | -------------------------------------------------------------------------------- /prisma/migrations/20230828143722_add_unique_constraint_to_the_slug_and_ownerid_columns/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[slug,ownerId]` on the table `boards` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- CreateIndex 8 | CREATE UNIQUE INDEX `boards_slug_ownerId_key` ON `boards`(`slug`, `ownerId`); 9 | -------------------------------------------------------------------------------- /prisma/migrations/20230830160003_add_starredboards_pivot/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `_StarredBoards` ( 3 | `A` VARCHAR(191) NOT NULL, 4 | `B` VARCHAR(191) NOT NULL, 5 | 6 | UNIQUE INDEX `_StarredBoards_AB_unique`(`A`, `B`), 7 | INDEX `_StarredBoards_B_index`(`B`) 8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 9 | 10 | -- AddForeignKey 11 | ALTER TABLE `_StarredBoards` ADD CONSTRAINT `_StarredBoards_A_fkey` FOREIGN KEY (`A`) REFERENCES `boards`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 12 | 13 | -- AddForeignKey 14 | ALTER TABLE `_StarredBoards` ADD CONSTRAINT `_StarredBoards_B_fkey` FOREIGN KEY (`B`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 15 | -------------------------------------------------------------------------------- /prisma/migrations/20230902154755_rename_the_image_column_to_background/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `image` on the `boards` table. All the data in the column will be lost. 5 | - Added the required column `background` to the `boards` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE `boards` DROP COLUMN `image`, 10 | ADD COLUMN `background` JSON NOT NULL; 11 | -------------------------------------------------------------------------------- /prisma/migrations/20231008153940_fix_table_names/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `labels` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `title` VARCHAR(191) NOT NULL, 5 | `color` VARCHAR(191) NOT NULL, 6 | `boardId` VARCHAR(191) NOT NULL, 7 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 8 | `updatedAt` DATETIME(3) NOT NULL, 9 | 10 | PRIMARY KEY (`id`) 11 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 12 | 13 | -- CreateTable 14 | CREATE TABLE `lists` ( 15 | `id` VARCHAR(191) NOT NULL, 16 | `title` VARCHAR(191) NOT NULL, 17 | `boardId` VARCHAR(191) NOT NULL, 18 | `position` INTEGER NOT NULL, 19 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 20 | `updatedAt` DATETIME(3) NOT NULL, 21 | 22 | PRIMARY KEY (`id`) 23 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 24 | 25 | -- CreateTable 26 | CREATE TABLE `cards` ( 27 | `id` VARCHAR(191) NOT NULL, 28 | `title` VARCHAR(191) NOT NULL, 29 | `description` LONGTEXT NULL, 30 | `position` INTEGER NOT NULL, 31 | `listId` VARCHAR(191) NOT NULL, 32 | `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 33 | `updatedAt` DATETIME(3) NOT NULL, 34 | 35 | PRIMARY KEY (`id`) 36 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 37 | 38 | -- CreateTable 39 | CREATE TABLE `_CardToLabel` ( 40 | `A` VARCHAR(191) NOT NULL, 41 | `B` VARCHAR(191) NOT NULL, 42 | 43 | UNIQUE INDEX `_CardToLabel_AB_unique`(`A`, `B`), 44 | INDEX `_CardToLabel_B_index`(`B`) 45 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 46 | 47 | -- AddForeignKey 48 | ALTER TABLE `labels` ADD CONSTRAINT `labels_boardId_fkey` FOREIGN KEY (`boardId`) REFERENCES `boards`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 49 | 50 | -- AddForeignKey 51 | ALTER TABLE `lists` ADD CONSTRAINT `lists_boardId_fkey` FOREIGN KEY (`boardId`) REFERENCES `boards`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 52 | 53 | -- AddForeignKey 54 | ALTER TABLE `cards` ADD CONSTRAINT `cards_listId_fkey` FOREIGN KEY (`listId`) REFERENCES `lists`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 55 | 56 | -- AddForeignKey 57 | ALTER TABLE `_CardToLabel` ADD CONSTRAINT `_CardToLabel_A_fkey` FOREIGN KEY (`A`) REFERENCES `cards`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 58 | 59 | -- AddForeignKey 60 | ALTER TABLE `_CardToLabel` ADD CONSTRAINT `_CardToLabel_B_fkey` FOREIGN KEY (`B`) REFERENCES `labels`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 61 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "mysql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id String @id @default(uuid()) 12 | name String 13 | email String @unique 14 | username String @unique 15 | password String? 16 | status String? 17 | image String? 18 | active Boolean? 19 | role RoleEnum @default(USER) 20 | boards Board[] @relation("OwnedBoards") 21 | guestBoards Board[] @relation("GuestBoards") 22 | starredBoards Board[] @relation("StarredBoards") 23 | createdAt DateTime @default(now()) 24 | updatedAt DateTime @updatedAt 25 | 26 | @@map(name: "users") 27 | } 28 | 29 | enum RoleEnum { 30 | USER 31 | ADMIN 32 | } 33 | 34 | model Board { 35 | id String @id @default(uuid()) 36 | name String 37 | slug String 38 | background Json 39 | public Boolean 40 | owner User @relation("OwnedBoards", fields: [ownerId], references: [id]) 41 | ownerId String 42 | members User[] @relation("GuestBoards") 43 | starsGivenBy User[] @relation("StarredBoards") 44 | labels Label[] 45 | lists List[] 46 | createdAt DateTime @default(now()) 47 | updatedAt DateTime @updatedAt 48 | 49 | @@unique([slug, ownerId]) 50 | @@map(name: "boards") 51 | } 52 | 53 | model Label { 54 | id String @id @default(uuid()) 55 | title String 56 | color String 57 | board Board @relation(fields: [boardId], references: [id]) 58 | boardId String 59 | cards Card[] 60 | createdAt DateTime @default(now()) 61 | updatedAt DateTime @updatedAt 62 | 63 | @@map(name: "labels") 64 | } 65 | 66 | model List { 67 | id String @id @default(uuid()) 68 | board Board @relation(fields: [boardId], references: [id]) 69 | title String 70 | boardId String 71 | position Int 72 | cards Card[] 73 | createdAt DateTime @default(now()) 74 | updatedAt DateTime @updatedAt 75 | 76 | @@map(name: "lists") 77 | } 78 | 79 | model Card { 80 | id String @id @default(uuid()) 81 | title String 82 | description String? @db.LongText 83 | position Int 84 | labels Label[] 85 | list List @relation(fields: [listId], references: [id]) 86 | listId String 87 | createdAt DateTime @default(now()) 88 | updatedAt DateTime @updatedAt 89 | 90 | @@map(name: "cards") 91 | } 92 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import * as bcrypt from "bcrypt"; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | async function main() { 7 | const password = await bcrypt.hash("test", 10); 8 | 9 | await prisma.user.createMany({ 10 | data: [ 11 | { 12 | id: "user-1", 13 | email: "test@gmail.com", 14 | name: "Test User", 15 | username: "test", 16 | password, 17 | }, 18 | { 19 | id: "user-2", 20 | email: "john@gmail.com", 21 | name: "John Doe", 22 | username: "john", 23 | password, 24 | }, 25 | ], 26 | }); 27 | 28 | await prisma.board.createMany({ 29 | data: [ 30 | { 31 | id: "board-1", 32 | name: "Test Board", 33 | slug: "test-board", 34 | ownerId: "user-1", 35 | public: true, 36 | background: { type: "color", value: "#1abc9c" }, 37 | }, 38 | { 39 | id: "board-2", 40 | name: "Test Board 2", 41 | slug: "test-board-2", 42 | ownerId: "user-1", 43 | public: false, 44 | background: { 45 | type: "image", 46 | value: 47 | "https://images.unsplash.com/photo-1470115636492-6d2b56f9146d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=MnwxfDB8MXxyYW5kb218MHx8bGFuZHNjYXBlfHx8fHx8MTY5MzIyNjY3Mg&ixlib=rb-4.0.3&q=80&w=1080", 48 | }, 49 | }, 50 | { 51 | id: "board-3", 52 | name: "Test Board 3", 53 | slug: "test-board-3", 54 | ownerId: "user-2", 55 | public: false, 56 | background: { type: "color", value: "#e67e22" }, 57 | }, 58 | ], 59 | }); 60 | 61 | await prisma.user.update({ 62 | where: { id: "user-1" }, 63 | data: { 64 | starredBoards: { connect: { id: "board-2" } }, 65 | guestBoards: { connect: { id: "board-3" } }, 66 | }, 67 | }); 68 | 69 | await prisma.user.update({ 70 | where: { id: "user-2" }, 71 | data: { 72 | guestBoards: { connect: { id: "board-2" } }, 73 | }, 74 | }); 75 | 76 | await prisma.label.createMany({ 77 | data: [ 78 | { 79 | id: "label-1", 80 | title: "Backend", 81 | color: "#6622cc", 82 | boardId: "board-2", 83 | }, 84 | { 85 | id: "label-2", 86 | title: "Frontend", 87 | color: "#22ccaa", 88 | boardId: "board-2", 89 | }, 90 | ], 91 | }); 92 | 93 | await prisma.list.create({ 94 | data: { 95 | id: "list-1", 96 | position: 1, 97 | title: "Backlog", 98 | boardId: "board-2", 99 | cards: { 100 | create: [ 101 | { 102 | title: "Test card", 103 | description: "This is a test card with HTML formatting.", 104 | position: 1, 105 | labels: { 106 | connect: [{ id: "label-1" }, { id: "label-2" }], 107 | }, 108 | }, 109 | { 110 | title: "Another test card", 111 | position: 2, 112 | }, 113 | ], 114 | }, 115 | }, 116 | }); 117 | } 118 | 119 | main() 120 | .then(async () => { 121 | await prisma.$disconnect(); 122 | }) 123 | .catch(async (e) => { 124 | console.error(e); 125 | await prisma.$disconnect(); 126 | process.exit(1); 127 | }); 128 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/(auth)/login/discord-auth.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { FC } from "react"; 4 | import { useSearchParams } from "next/navigation"; 5 | import { ClientSafeProvider, signIn } from "next-auth/react"; 6 | import { BsDiscord } from "react-icons/bs"; 7 | import { Button } from "@/components/ui/button"; 8 | 9 | interface DiscordAuthProps { 10 | discord?: ClientSafeProvider; 11 | } 12 | 13 | const DiscordAuth: FC = ({ discord }) => { 14 | const params = useSearchParams(); 15 | 16 | return ( 17 | 30 | ); 31 | }; 32 | 33 | export default DiscordAuth; 34 | -------------------------------------------------------------------------------- /src/app/(auth)/login/github-auth.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { FC } from "react"; 4 | import { useSearchParams } from "next/navigation"; 5 | import { ClientSafeProvider, signIn } from "next-auth/react"; 6 | import { FaGithub } from "react-icons/fa"; 7 | import { Button } from "@/components/ui/button"; 8 | 9 | interface GitHubAuthProps { 10 | github?: ClientSafeProvider; 11 | } 12 | 13 | const GithubAuth: FC = ({ github }) => { 14 | const params = useSearchParams(); 15 | 16 | return ( 17 | 30 | ); 31 | }; 32 | 33 | export default GithubAuth; 34 | -------------------------------------------------------------------------------- /src/app/(auth)/login/login-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import Link from "next/link"; 5 | import { useRouter, useSearchParams } from "next/navigation"; 6 | import { zodResolver } from "@hookform/resolvers/zod"; 7 | import { signIn } from "next-auth/react"; 8 | import { useForm } from "react-hook-form"; 9 | import * as z from "zod"; 10 | import { loginSchema } from "@/lib/schemas/login"; 11 | import { Alert } from "@/components/ui/alert"; 12 | import { Button } from "@/components/ui/button"; 13 | import { 14 | Form, 15 | FormControl, 16 | FormField, 17 | FormItem, 18 | FormLabel, 19 | FormMessage, 20 | } from "@/components/ui/form"; 21 | import { Input } from "@/components/ui/input"; 22 | import LoadingButton from "@/components/ui/loading-button"; 23 | 24 | const LoginForm = () => { 25 | const params = useSearchParams(); 26 | const router = useRouter(); 27 | 28 | const [error, setError] = useState(); 29 | 30 | const form = useForm>({ 31 | resolver: zodResolver(loginSchema), 32 | defaultValues: { 33 | username: params.get("email") ?? "", 34 | password: "", 35 | }, 36 | }); 37 | 38 | const onSubmit = async (values: z.infer) => { 39 | const res = await signIn("credentials", { 40 | ...values, 41 | callbackUrl: params.get("callbackUrl") ?? "/", 42 | redirect: false, 43 | }); 44 | 45 | if (!res || res.error) { 46 | setError("Invalid login credentials"); 47 | } else { 48 | router.push(params.get("callbackUrl") ?? "/"); 49 | } 50 | }; 51 | 52 | return ( 53 |
54 | 58 | {error && {error}} 59 | 60 | ( 64 | 65 | E-mail or username 66 | 67 | 72 | 73 | 74 | 75 | )} 76 | /> 77 | 78 | ( 82 | 83 | Password 84 | 85 | 91 | 92 | 93 | 94 | )} 95 | /> 96 | 97 | 105 | 106 | 112 | Submit 113 | 114 | 115 | 116 | ); 117 | }; 118 | 119 | export default LoginForm; 120 | -------------------------------------------------------------------------------- /src/app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Metadata } from "next"; 3 | import { getProviders } from "next-auth/react"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import DiscordAuth from "@/app/(auth)/login/discord-auth"; 12 | import GithubAuth from "@/app/(auth)/login/github-auth"; 13 | import LoginForm from "@/app/(auth)/login/login-form"; 14 | 15 | export const metadata: Metadata = { 16 | title: "Login", 17 | }; 18 | 19 | const LoginPage = async () => { 20 | const providers = await getProviders(); 21 | 22 | return ( 23 |
24 | 25 | 26 | Login 27 | Trello Clone 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 | 41 | 42 |
43 |
44 | ); 45 | }; 46 | 47 | export default LoginPage; 48 | -------------------------------------------------------------------------------- /src/app/(auth)/register/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import * as bcrypt from "bcrypt"; 4 | import * as z from "zod"; 5 | import { prisma } from "@/lib/prisma"; 6 | import { registerSchema } from "@/lib/schemas/register"; 7 | import { 8 | generateRandomHex, 9 | generateUsernameFromEmail, 10 | slugify, 11 | } from "@/lib/utils"; 12 | import { createBoard } from "@/app/(dashboard)/new/actions"; 13 | 14 | export async function createUser(user: z.infer) { 15 | const res = registerSchema.safeParse(user); 16 | if (!res.success) { 17 | throw new Error(res.error.errors.at(0)?.message); 18 | } 19 | 20 | const foundUser = await prisma.user.findUnique({ 21 | where: { email: user.email }, 22 | }); 23 | if (foundUser) { 24 | throw new Error("User with this email already exists"); 25 | } 26 | 27 | const { passwordConfirmation, ...data } = user; 28 | 29 | const password = await bcrypt.hash(data.password, 10); 30 | const newUser = await prisma.user.create({ 31 | data: { 32 | ...data, 33 | username: generateUsernameFromEmail(data.email), 34 | password, 35 | }, 36 | }); 37 | 38 | await createBoard(newUser.id, { 39 | name: `${newUser.name}'s Board`, 40 | slug: `${slugify(newUser.name)}s-board`, 41 | public: false, 42 | background: { type: "color", value: generateRandomHex() }, 43 | }); 44 | 45 | return newUser; 46 | } 47 | -------------------------------------------------------------------------------- /src/app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Metadata } from "next"; 3 | import { 4 | Card, 5 | CardContent, 6 | CardDescription, 7 | CardHeader, 8 | CardTitle, 9 | } from "@/components/ui/card"; 10 | import RegisterForm from "@/app/(auth)/register/registration-form"; 11 | 12 | export const metadata: Metadata = { 13 | title: "Registration", 14 | }; 15 | 16 | const LoginPage = async () => { 17 | return ( 18 |
19 | 20 | 21 | Register 22 | Trello Clone 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | ); 31 | }; 32 | 33 | export default LoginPage; 34 | -------------------------------------------------------------------------------- /src/app/(auth)/register/registration-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import Link from "next/link"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { signIn } from "next-auth/react"; 7 | import { useForm } from "react-hook-form"; 8 | import * as z from "zod"; 9 | import { registerSchema } from "@/lib/schemas/register"; 10 | import { Alert } from "@/components/ui/alert"; 11 | import { Button } from "@/components/ui/button"; 12 | import { 13 | Form, 14 | FormControl, 15 | FormField, 16 | FormItem, 17 | FormLabel, 18 | FormMessage, 19 | } from "@/components/ui/form"; 20 | import { Input } from "@/components/ui/input"; 21 | import LoadingButton from "@/components/ui/loading-button"; 22 | import { createUser } from "@/app/(auth)/register/actions"; 23 | 24 | const RegistrationForm = () => { 25 | const [error, setError] = useState(); 26 | 27 | const form = useForm>({ 28 | resolver: zodResolver(registerSchema), 29 | defaultValues: { 30 | email: "", 31 | name: "", 32 | password: "", 33 | passwordConfirmation: "", 34 | }, 35 | }); 36 | 37 | const onSubmit = async (values: z.infer) => { 38 | try { 39 | await createUser(values); 40 | await signIn("credentials", { 41 | username: values.email, 42 | password: values.password, 43 | callbackUrl: "/", 44 | redirect: true, 45 | }); 46 | } catch (e: any) { 47 | setError(e.message); 48 | } 49 | }; 50 | 51 | return ( 52 |
53 | 57 | {error && {error}} 58 | 59 | ( 63 | 64 | Name 65 | 66 | 71 | 72 | 73 | 74 | )} 75 | /> 76 | 77 | ( 81 | 82 | E-mail 83 | 84 | 89 | 90 | 91 | 92 | )} 93 | /> 94 | 95 | ( 99 | 100 | Password 101 | 102 | 108 | 109 | 110 | 111 | )} 112 | /> 113 | 114 | ( 118 | 119 | Password Confirmation 120 | 121 | 127 | 128 | 129 | 130 | )} 131 | /> 132 | 133 | 141 | 142 | 148 | Submit 149 | 150 | 151 | 152 | ); 153 | }; 154 | 155 | export default RegistrationForm; 156 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/activity/activity.tsx: -------------------------------------------------------------------------------- 1 | const Activity = () => { 2 | return ( 3 |
4 |

Activity

5 |
6 |
7 | ); 8 | }; 9 | 10 | export default Activity; 11 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/description/card-description.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useCardContext } from "@/context/card-context"; 5 | import parse from "html-react-parser"; 6 | import { cn } from "@/lib/utils"; 7 | import { Button } from "@/components/ui/button"; 8 | import DescriptionWysiwyg from "@/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/description/description-wysiwyg"; 9 | 10 | const CardDescription = () => { 11 | const { card, permission } = useCardContext(); 12 | const [descriptionEdit, setDescriptionEdit] = useState(false); 13 | 14 | return ( 15 |
16 |

17 | Description 18 |

19 | {descriptionEdit ? ( 20 | setDescriptionEdit(false)} /> 21 | ) : ( 22 | 39 | )} 40 |
41 | ); 42 | }; 43 | 44 | export default CardDescription; 45 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/description/description-wysiwyg.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC, FormEvent, useState, useTransition } from "react"; 4 | import { useCardContext } from "@/context/card-context"; 5 | import { Button } from "@/components/ui/button"; 6 | import LoadingButton from "@/components/ui/loading-button"; 7 | import RichTextEditor from "@/components/wysiwyg/rich-text-editor"; 8 | import { updateCard } from "@/app/(dashboard)/[username]/[boardSlug]/actions"; 9 | 10 | interface DescriptionWysiwygProps { 11 | onReturn: () => void; 12 | } 13 | 14 | const DescriptionWysiwyg: FC = ({ onReturn }) => { 15 | const { card, revalidateCard } = useCardContext(); 16 | const [isSubmitting, startTransition] = useTransition(); 17 | const [content, setContent] = useState(card.description ?? ""); 18 | 19 | function handleSubmit(e: FormEvent) { 20 | e.preventDefault(); 21 | startTransition(async () => { 22 | await updateCard(card.id, { description: content }); 23 | await revalidateCard(); 24 | onReturn(); 25 | }); 26 | } 27 | 28 | return ( 29 |
30 | 31 | 32 |
33 | 34 | Save 35 | 36 | 39 |
40 | 41 | ); 42 | }; 43 | 44 | export default DescriptionWysiwyg; 45 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/dialog-wrapper.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { FC, PropsWithChildren } from "react"; 4 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 5 | 6 | interface DialogWrapperProps extends PropsWithChildren {} 7 | 8 | const DialogWrapper: FC = ({ children }) => { 9 | return ( 10 | 11 | 15 | {children} 16 | 17 | 18 | ); 19 | }; 20 | 21 | export default DialogWrapper; 22 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/header/card-header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { revalidatePath } from "next/cache"; 4 | import { useRouter } from "next/navigation"; 5 | import { useCardContext } from "@/context/card-context"; 6 | import { X } from "lucide-react"; 7 | import { Button } from "@/components/ui/button"; 8 | import { 9 | DialogDescription, 10 | DialogHeader, 11 | DialogTitle, 12 | } from "@/components/ui/dialog"; 13 | import EditableTitle from "@/components/editable-title"; 14 | import { updateCard } from "@/app/(dashboard)/[username]/[boardSlug]/actions"; 15 | 16 | const CardHeader = () => { 17 | const router = useRouter(); 18 | const { card, board } = useCardContext(); 19 | 20 | return ( 21 | 22 |
23 | 24 | updateCard(card.id, { title })} 27 | className="w-ful text-3xl font-semibold hover:bg-transparent" 28 | /> 29 | 30 | 31 | 42 |
43 | 44 | 45 | in list {card.list.title} 46 | 47 |
48 | ); 49 | }; 50 | 51 | export default CardHeader; 52 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/labels/label-list.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useMemo, useState } from "react"; 2 | import { useCardContext } from "@/context/card-context"; 3 | import { Label } from "@prisma/client"; 4 | import { PencilIcon } from "lucide-react"; 5 | import { cn, getTextColor } from "@/lib/utils"; 6 | import { Button } from "@/components/ui/button"; 7 | import { Checkbox } from "@/components/ui/checkbox"; 8 | import { 9 | DropdownMenuContent, 10 | DropdownMenuLabel, 11 | DropdownMenuSeparator, 12 | } from "@/components/ui/dropdown-menu"; 13 | import { Input } from "@/components/ui/input"; 14 | 15 | interface LabelListProps { 16 | onCreateBtnClick: () => void; 17 | onEditBtnClick: (label: Label) => void; 18 | } 19 | 20 | const LabelList: FC = ({ 21 | onCreateBtnClick, 22 | onEditBtnClick, 23 | }) => { 24 | const { board, card, toggleLabel } = useCardContext(); 25 | const cardLabelIds = card.labels.map((l) => l.id); 26 | 27 | const [searchTerm, setSearchTerm] = useState(""); 28 | const boardLabels = useMemo(() => { 29 | if (!searchTerm.trim()) return board.labels; 30 | 31 | return board.labels.filter((x) => 32 | x.title.toLowerCase().includes(searchTerm.toLowerCase()) 33 | ); 34 | }, [board.labels, searchTerm]); 35 | 36 | return ( 37 | 38 | Labels 39 | 40 | 41 |
42 | setSearchTerm(e.target.value)} 45 | placeholder="Search labels..." 46 | /> 47 | 48 | {boardLabels.map((label) => ( 49 |
50 | toggleLabel(label, !e)} 55 | /> 56 | 66 | 73 |
74 | ))} 75 | 76 | 83 |
84 |
85 | ); 86 | }; 87 | 88 | export default LabelList; 89 | -------------------------------------------------------------------------------- /src/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/labels/labels.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { useCardContext } from "@/context/card-context"; 5 | import { Label } from "@prisma/client"; 6 | import { Plus } from "lucide-react"; 7 | import { getTextColor } from "@/lib/utils"; 8 | import { Badge } from "@/components/ui/badge"; 9 | import { Button } from "@/components/ui/button"; 10 | import { 11 | DropdownMenu, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu"; 14 | import LabelForm from "@/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/labels/label-form"; 15 | import LabelList from "@/app/(dashboard)/[username]/[boardSlug]/@card/(.)cards/[cardId]/_components/labels/label-list"; 16 | 17 | const Labels = () => { 18 | const { card, permission } = useCardContext(); 19 | const [activeDropdown, setActiveDropdown] = useState<"FORM" | "LIST">("LIST"); 20 | const [activeLabel, setActiveLabel] = useState