From f3e2f01bd7341ee309f6b10d10791d96d1caa0c0 Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Wed, 14 Feb 2024 21:30:10 -0500 Subject: [PATCH] Initial Commit --- .eslintrc.json | 3 + .gitignore | 40 + LICENCE | 201 + README.md | 3 + actions/copy-card/index.ts | 69 + actions/copy-card/schema.ts | 6 + actions/copy-card/types.ts | 9 + actions/copy-list/index.ts | 82 + actions/copy-list/schema.ts | 6 + actions/copy-list/types.ts | 9 + actions/create-board/index.ts | 87 + actions/create-board/schema.ts | 14 + actions/create-board/types.ts | 8 + actions/create-card/index.ts | 66 + actions/create-card/schema.ts | 12 + actions/create-card/types.ts | 9 + actions/create-list/index.ts | 64 + actions/create-list/schema.ts | 11 + actions/create-list/types.ts | 9 + actions/delete-board/index.ts | 55 + actions/delete-board/schema.ts | 5 + actions/delete-board/types.ts | 9 + actions/delete-card/index.ts | 50 + actions/delete-card/schema.ts | 6 + actions/delete-card/types.ts | 9 + actions/delete-list/index.ts | 49 + actions/delete-list/schema.ts | 6 + actions/delete-list/types.ts | 9 + actions/stripe-redirect/index.ts | 75 + actions/stripe-redirect/schema.ts | 3 + actions/stripe-redirect/types.ts | 8 + actions/update-board/index.ts | 49 + actions/update-board/schema.ts | 11 + actions/update-board/types.ts | 9 + actions/update-card-order/index.ts | 49 + actions/update-card-order/schema.ts | 15 + actions/update-card-order/types.ts | 9 + actions/update-card/index.ts | 53 + actions/update-card/schema.ts | 26 + actions/update-card/types.ts | 9 + actions/update-list-order/index.ts | 46 + actions/update-list-order/schema.ts | 14 + actions/update-list-order/types.ts | 9 + actions/update-list/index.ts | 52 + actions/update-list/schema.ts | 12 + actions/update-list/types.ts | 9 + app/(marketing)/_components/footer.tsx | 20 + app/(marketing)/_components/navbar.tsx | 21 + app/(marketing)/layout.tsx | 14 + app/(marketing)/page.tsx | 54 + app/(platform)/(clerk)/layout.tsx | 9 + .../select-org/[[...select-org]]/page.tsx | 11 + .../(clerk)/sign-in/[[...signin]]/page.tsx | 5 + .../(clerk)/sign-up/[[...sign-up]]/page.tsx | 5 + .../(dashboard)/_components/Navbar.tsx | 67 + .../_components/mobile-sidebar.tsx | 49 + .../(dashboard)/_components/nav-item.tsx | 117 + .../(dashboard)/_components/sidebar.tsx | 95 + .../[boardId]/_components/board-navbar.tsx | 19 + .../[boardId]/_components/board-options.tsx | 61 + .../_components/board-title-form.tsx | 86 + .../board/[boardId]/_components/card-form.tsx | 104 + .../board/[boardId]/_components/card-item.tsx | 32 + .../[boardId]/_components/list-container.tsx | 163 + .../board/[boardId]/_components/list-form.tsx | 102 + .../[boardId]/_components/list-header.tsx | 98 + .../board/[boardId]/_components/list-item.tsx | 76 + .../[boardId]/_components/list-options.tsx | 113 + .../[boardId]/_components/list-wrapper.tsx | 7 + .../(dashboard)/board/[boardId]/layout.tsx | 60 + .../(dashboard)/board/[boardId]/page.tsx | 46 + app/(platform)/(dashboard)/layout.tsx | 12 + .../_components/board-list.tsx | 90 + .../[organizationId]/_components/info.tsx | 56 + .../_components/org-control.tsx | 20 + .../activity/_components/activity-list.tsx | 44 + .../[organizationId]/activity/page.tsx | 23 + .../_components/subscription-button.tsx | 39 + .../[organizationId]/billing/page.tsx | 19 + .../organization/[organizationId]/layout.tsx | 23 + .../organization/[organizationId]/page.tsx | 25 + .../[organizationId]/settings/page.tsx | 25 + .../(dashboard)/organization/layout.tsx | 16 + app/(platform)/layout.tsx | 19 + app/api/cards/[cardId]/logs/route.ts | 35 + app/api/cards/[cardId]/route.ts | 40 + app/api/webhook/route.ts | 69 + app/globals.css | 82 + app/layout.tsx | 32 + components.json | 17 + components/activity-item.tsx | 30 + components/form/form-errors.tsx | 28 + components/form/form-input.tsx | 73 + components/form/form-picker.tsx | 109 + components/form/form-popover.tsx | 94 + components/form/form-submit.tsx | 41 + components/form/form-textarea.tsx | 80 + components/hint.tsx | 35 + components/logo.tsx | 12 + components/modals/card-modal/actions.tsx | 103 + components/modals/card-modal/activity.tsx | 39 + components/modals/card-modal/description.tsx | 126 + components/modals/card-modal/header.tsx | 93 + components/modals/card-modal/index.tsx | 55 + components/modals/pro-modal.tsx | 61 + components/providers/modal-provider.tsx | 23 + components/providers/query-provider.tsx | 18 + components/ui/accordion.tsx | 58 + components/ui/avatar.tsx | 50 + components/ui/button.tsx | 60 + components/ui/dialog.tsx | 122 + components/ui/input.tsx | 25 + components/ui/label.tsx | 26 + components/ui/popover.tsx | 33 + components/ui/separator.tsx | 31 + components/ui/sheet.tsx | 140 + components/ui/skeleton.tsx | 15 + components/ui/textarea.tsx | 24 + components/ui/tooltip.tsx | 30 + config/site.ts | 4 + constants/boards.ts | 1 + constants/images.ts | 977 +++ hooks/use-action.ts | 58 + hooks/use-card-modal.ts | 15 + hooks/use-mobile-sidebar.ts | 13 + hooks/use-pro-modal.ts | 13 + lib/create-audit-log.ts | 37 + lib/create-safe-action.ts | 28 + lib/db.ts | 9 + lib/fetcher.ts | 1 + lib/generate-log-message.ts | 16 + lib/org-limit.ts | 86 + lib/stripe.ts | 6 + lib/subscription.ts | 35 + lib/unsplash.ts | 6 + lib/utils.ts | 10 + middleware.ts | 34 + next.config.js | 17 + package-lock.json | 6457 +++++++++++++++++ package.json | 55 + postcss.config.js | 6 + prisma/schema.prisma | 103 + public/favicon.svg | 38 + public/fonts/font.woff2 | Bin 0 -> 40932 bytes public/hero.svg | 39 + public/logo-full.svg | 276 + public/logo-transparent.svg | 38 + tailwind.config.ts | 80 + tsconfig.json | 27 + types.ts | 9 + 150 files changed, 13612 insertions(+) create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 LICENCE create mode 100644 README.md create mode 100644 actions/copy-card/index.ts create mode 100644 actions/copy-card/schema.ts create mode 100644 actions/copy-card/types.ts create mode 100644 actions/copy-list/index.ts create mode 100644 actions/copy-list/schema.ts create mode 100644 actions/copy-list/types.ts create mode 100644 actions/create-board/index.ts create mode 100644 actions/create-board/schema.ts create mode 100644 actions/create-board/types.ts create mode 100644 actions/create-card/index.ts create mode 100644 actions/create-card/schema.ts create mode 100644 actions/create-card/types.ts create mode 100644 actions/create-list/index.ts create mode 100644 actions/create-list/schema.ts create mode 100644 actions/create-list/types.ts create mode 100644 actions/delete-board/index.ts create mode 100644 actions/delete-board/schema.ts create mode 100644 actions/delete-board/types.ts create mode 100644 actions/delete-card/index.ts create mode 100644 actions/delete-card/schema.ts create mode 100644 actions/delete-card/types.ts create mode 100644 actions/delete-list/index.ts create mode 100644 actions/delete-list/schema.ts create mode 100644 actions/delete-list/types.ts create mode 100644 actions/stripe-redirect/index.ts create mode 100644 actions/stripe-redirect/schema.ts create mode 100644 actions/stripe-redirect/types.ts create mode 100644 actions/update-board/index.ts create mode 100644 actions/update-board/schema.ts create mode 100644 actions/update-board/types.ts create mode 100644 actions/update-card-order/index.ts create mode 100644 actions/update-card-order/schema.ts create mode 100644 actions/update-card-order/types.ts create mode 100644 actions/update-card/index.ts create mode 100644 actions/update-card/schema.ts create mode 100644 actions/update-card/types.ts create mode 100644 actions/update-list-order/index.ts create mode 100644 actions/update-list-order/schema.ts create mode 100644 actions/update-list-order/types.ts create mode 100644 actions/update-list/index.ts create mode 100644 actions/update-list/schema.ts create mode 100644 actions/update-list/types.ts create mode 100644 app/(marketing)/_components/footer.tsx create mode 100644 app/(marketing)/_components/navbar.tsx create mode 100644 app/(marketing)/layout.tsx create mode 100644 app/(marketing)/page.tsx create mode 100644 app/(platform)/(clerk)/layout.tsx create mode 100644 app/(platform)/(clerk)/select-org/[[...select-org]]/page.tsx create mode 100644 app/(platform)/(clerk)/sign-in/[[...signin]]/page.tsx create mode 100644 app/(platform)/(clerk)/sign-up/[[...sign-up]]/page.tsx create mode 100644 app/(platform)/(dashboard)/_components/Navbar.tsx create mode 100644 app/(platform)/(dashboard)/_components/mobile-sidebar.tsx create mode 100644 app/(platform)/(dashboard)/_components/nav-item.tsx create mode 100644 app/(platform)/(dashboard)/_components/sidebar.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/board-navbar.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/board-options.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/board-title-form.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/card-form.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/card-item.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/list-container.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/list-form.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/list-header.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/list-item.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/list-options.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/_components/list-wrapper.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/layout.tsx create mode 100644 app/(platform)/(dashboard)/board/[boardId]/page.tsx create mode 100644 app/(platform)/(dashboard)/layout.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/_components/board-list.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/_components/info.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/_components/org-control.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/activity/_components/activity-list.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/activity/page.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/billing/_components/subscription-button.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/billing/page.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/layout.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/page.tsx create mode 100644 app/(platform)/(dashboard)/organization/[organizationId]/settings/page.tsx create mode 100644 app/(platform)/(dashboard)/organization/layout.tsx create mode 100644 app/(platform)/layout.tsx create mode 100644 app/api/cards/[cardId]/logs/route.ts create mode 100644 app/api/cards/[cardId]/route.ts create mode 100644 app/api/webhook/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 components.json create mode 100644 components/activity-item.tsx create mode 100644 components/form/form-errors.tsx create mode 100644 components/form/form-input.tsx create mode 100644 components/form/form-picker.tsx create mode 100644 components/form/form-popover.tsx create mode 100644 components/form/form-submit.tsx create mode 100644 components/form/form-textarea.tsx create mode 100644 components/hint.tsx create mode 100644 components/logo.tsx create mode 100644 components/modals/card-modal/actions.tsx create mode 100644 components/modals/card-modal/activity.tsx create mode 100644 components/modals/card-modal/description.tsx create mode 100644 components/modals/card-modal/header.tsx create mode 100644 components/modals/card-modal/index.tsx create mode 100644 components/modals/pro-modal.tsx create mode 100644 components/providers/modal-provider.tsx create mode 100644 components/providers/query-provider.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 config/site.ts create mode 100644 constants/boards.ts create mode 100644 constants/images.ts create mode 100644 hooks/use-action.ts create mode 100644 hooks/use-card-modal.ts create mode 100644 hooks/use-mobile-sidebar.ts create mode 100644 hooks/use-pro-modal.ts create mode 100644 lib/create-audit-log.ts create mode 100644 lib/create-safe-action.ts create mode 100644 lib/db.ts create mode 100644 lib/fetcher.ts create mode 100644 lib/generate-log-message.ts create mode 100644 lib/org-limit.ts create mode 100644 lib/stripe.ts create mode 100644 lib/subscription.ts create mode 100644 lib/unsplash.ts create mode 100644 lib/utils.ts create mode 100644 middleware.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 public/favicon.svg create mode 100644 public/fonts/font.woff2 create mode 100644 public/hero.svg create mode 100644 public/logo-full.svg create mode 100644 public/logo-transparent.svg create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types.ts diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94897b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# other +/.vscode/ diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/LICENCE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1bf018 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Tasko + +## TODO: Finish README diff --git a/actions/copy-card/index.ts b/actions/copy-card/index.ts new file mode 100644 index 0000000..e6c040d --- /dev/null +++ b/actions/copy-card/index.ts @@ -0,0 +1,69 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { CopyCard } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { id, boardId } = data; + let card; + + try { + const cardToCopy = await db.card.findUnique({ + where: { + id, + list: { + board: { + orgId, + }, + }, + }, + }); + + if (!cardToCopy) return { error: "Card not found" }; + + const lastCard = await db.card.findFirst({ + where: { listId: cardToCopy.listId }, + orderBy: { order: "desc" }, + select: { order: true }, + }); + + const newOrder = lastCard ? lastCard.order + 1 : 1; + + card = await db.card.create({ + data: { + title: `${cardToCopy.title} - Copy`, + description: cardToCopy.description, + order: newOrder, + listId: cardToCopy.listId, + } + }) + + await createAuditLog({ + entityTitle: card.title, + entityType: ENTITY_TYPE.CARD, + entityId: card.id, + action: ACTION.CREATE, + }); + } catch (error) { + return { + error: "Failed to copy card", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: card }; +}; + +export const copyCard = createSafeAction(CopyCard, handler); diff --git a/actions/copy-card/schema.ts b/actions/copy-card/schema.ts new file mode 100644 index 0000000..61d5b63 --- /dev/null +++ b/actions/copy-card/schema.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const CopyCard = z.object({ + id: z.string(), + boardId: z.string(), +}); diff --git a/actions/copy-card/types.ts b/actions/copy-card/types.ts new file mode 100644 index 0000000..37711d8 --- /dev/null +++ b/actions/copy-card/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { Card } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { CopyCard } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; diff --git a/actions/copy-list/index.ts b/actions/copy-list/index.ts new file mode 100644 index 0000000..a6045e3 --- /dev/null +++ b/actions/copy-list/index.ts @@ -0,0 +1,82 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { CopyList } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { id, boardId } = data; + let list; + + try { + const listToCopy = await db.list.findUnique({ + where: { + id, + boardId, + board: { + orgId, + }, + }, + include: { + cards: true, + }, + }); + + if (!listToCopy) return { error: "List not found" }; + + const lastList = await db.list.findFirst({ + where: { boardId }, + orderBy: { order: "desc" }, + select: { order: true }, + }); + + const newOrder = lastList ? lastList.order + 1 : 1; + + list = await db.list.create({ + data: { + boardId: listToCopy.boardId, + title: `${listToCopy.title} - Copy`, + order: newOrder, + cards: { + createMany: { + data: listToCopy.cards.map((card) => ({ + title: card.title, + description: card.description, + order: card.order, + })), + }, + }, + }, + include: { + cards: true, + }, + }); + + await createAuditLog({ + entityTitle: list.title, + entityType: ENTITY_TYPE.LIST, + entityId: list.id, + action: ACTION.CREATE, + }); + } catch (error) { + return { + error: "Failed to copy list", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: list }; +}; + +export const copyList = createSafeAction(CopyList, handler); diff --git a/actions/copy-list/schema.ts b/actions/copy-list/schema.ts new file mode 100644 index 0000000..497d6a0 --- /dev/null +++ b/actions/copy-list/schema.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const CopyList = z.object({ + id: z.string(), + boardId: z.string(), +}) \ No newline at end of file diff --git a/actions/copy-list/types.ts b/actions/copy-list/types.ts new file mode 100644 index 0000000..7ad51e8 --- /dev/null +++ b/actions/copy-list/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { List } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { CopyList } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/actions/create-board/index.ts b/actions/create-board/index.ts new file mode 100644 index 0000000..b6df208 --- /dev/null +++ b/actions/create-board/index.ts @@ -0,0 +1,87 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; + +import { db } from "@/lib/db"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { CreateBoard } from "./schema"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; +import { incrementAvailableCount, hasAvailableCount } from "@/lib/org-limit"; +import { checkSubscription } from "@/lib/subscription"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) { + return { + error: "Unauthorized", + }; + } + + const canCreate = await hasAvailableCount(); + const isPro = await checkSubscription(); + + if (!canCreate && !isPro) { + return { + error: + "You have reached your limit of free boards. Please upgrade to create more.", + }; + } + + const { title, image } = data; + + const [imageId, imageThumbUrl, imageFullUrl, imageLinkHTML, imageUserName] = + image.split("|"); + + if ( + !imageId || + !imageThumbUrl || + !imageFullUrl || + !imageUserName || + !imageLinkHTML + ) { + return { + error: "Missing fields. Failed to create board.", + }; + } + + let board; + + try { + board = await db.board.create({ + data: { + title, + orgId, + imageId, + imageThumbUrl, + imageFullUrl, + imageUserName, + imageLinkHTML, + }, + }); + + if (!isPro) { + await incrementAvailableCount(); + } + + await createAuditLog({ + entityTitle: board.title, + entityId: board.id, + entityType: ENTITY_TYPE.BOARD, + action: ACTION.CREATE, + }); + } catch (error) { + return { + error: "Failed to create board", + }; + } + + revalidatePath(`/board/${board.id}`); + return { data: board }; +}; + +export const createBoard = createSafeAction(CreateBoard, handler); diff --git a/actions/create-board/schema.ts b/actions/create-board/schema.ts new file mode 100644 index 0000000..0ac1aea --- /dev/null +++ b/actions/create-board/schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const CreateBoard = z.object({ + title: z.string({ + required_error: "Title is required", + invalid_type_error: "Title must be a string", + }).min(3, { + message: "Title must be at least 3 characters", + }), + image: z.string({ + required_error: "Image is required", + invalid_type_error: "Image must be a string", + }) +}); \ No newline at end of file diff --git a/actions/create-board/types.ts b/actions/create-board/types.ts new file mode 100644 index 0000000..03d9ef4 --- /dev/null +++ b/actions/create-board/types.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; +import { Board } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; +import { CreateBoard } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; diff --git a/actions/create-card/index.ts b/actions/create-card/index.ts new file mode 100644 index 0000000..8f08e29 --- /dev/null +++ b/actions/create-card/index.ts @@ -0,0 +1,66 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { CreateCard } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { title, boardId, listId } = data; + let card; + + try { + const list = await db.list.findUnique({ + where: { + id: listId, + board: { + orgId, + }, + }, + }); + + if (!list) return { error: "List not found" }; + + const lastCard = await db.card.findFirst({ + where: { listId }, + orderBy: { order: "desc" }, + select: { order: true }, + }); + + const newOrder = lastCard ? lastCard.order + 1 : 1; + + card = await db.card.create({ + data: { + title, + listId, + order: newOrder, + }, + }); + + await createAuditLog({ + entityId: card.id, + entityTitle: card.title, + entityType: ENTITY_TYPE.CARD, + action: ACTION.CREATE, + }); + } catch (error) { + return { + error: "Failed to create card", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: card }; +}; + +export const createCard = createSafeAction(CreateCard, handler); diff --git a/actions/create-card/schema.ts b/actions/create-card/schema.ts new file mode 100644 index 0000000..3b6d60b --- /dev/null +++ b/actions/create-card/schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const CreateCard = z.object({ + title: z.string({ + required_error: "Card title is required", + invalid_type_error: "Card title must be a string", + }).min(2, { + message: "Card title must be at least 2 characters", + }), + boardId: z.string(), + listId: z.string(), +}) \ No newline at end of file diff --git a/actions/create-card/types.ts b/actions/create-card/types.ts new file mode 100644 index 0000000..5df7e72 --- /dev/null +++ b/actions/create-card/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { Card } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { CreateCard } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; diff --git a/actions/create-list/index.ts b/actions/create-list/index.ts new file mode 100644 index 0000000..d021a35 --- /dev/null +++ b/actions/create-list/index.ts @@ -0,0 +1,64 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { CreateList } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { title, boardId } = data; + let list; + + try { + const board = await db.board.findUnique({ + where: { + id: boardId, + orgId, + }, + }); + + if (!board) return { error: "Board not found" }; + + const lastList = await db.list.findFirst({ + where: { boardId: boardId }, + orderBy: { order: "desc" }, + select: { order: true }, + }); + + const newOrder = lastList ? lastList.order + 1 : 1; + + list = await db.list.create({ + data: { + title, + boardId, + order: newOrder, + }, + }); + + await createAuditLog({ + entityTitle: list.title, + entityType: ENTITY_TYPE.LIST, + entityId: list.id, + action: ACTION.CREATE, + }); + } catch (error) { + return { + error: "Failed to create list", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: list }; +}; + +export const createList = createSafeAction(CreateList, handler); diff --git a/actions/create-list/schema.ts b/actions/create-list/schema.ts new file mode 100644 index 0000000..b16f986 --- /dev/null +++ b/actions/create-list/schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const CreateList = z.object({ + title: z.string({ + required_error: "List title is required", + invalid_type_error: "List title must be a string", + }).min(2, { + message: "List title must be at least 2 characters", + }), + boardId: z.string(), +}) \ No newline at end of file diff --git a/actions/create-list/types.ts b/actions/create-list/types.ts new file mode 100644 index 0000000..cfbe040 --- /dev/null +++ b/actions/create-list/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { List } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { CreateList } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/actions/delete-board/index.ts b/actions/delete-board/index.ts new file mode 100644 index 0000000..80020cd --- /dev/null +++ b/actions/delete-board/index.ts @@ -0,0 +1,55 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; +import { decreaseAvailableCount } from "@/lib/org-limit"; +import { checkSubscription } from "@/lib/subscription"; + +import { InputType, ReturnType } from "./types"; +import { DeleteBoard } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const isPro = await checkSubscription(); + + const { id } = data; + let board; + + try { + board = await db.board.delete({ + where: { + id, + orgId, + }, + }); + + if (!isPro) { + await decreaseAvailableCount(); + } + + await createAuditLog({ + entityTitle: board.title, + entityType: ENTITY_TYPE.BOARD, + entityId: board.id, + action: ACTION.DELETE, + }); + } catch (error) { + return { + error: "Failed to delete board", + }; + } + + revalidatePath(`/organization/${orgId}`); + redirect(`/organization/${orgId}`); +}; + +export const deleteBoard = createSafeAction(DeleteBoard, handler); diff --git a/actions/delete-board/schema.ts b/actions/delete-board/schema.ts new file mode 100644 index 0000000..4224b1f --- /dev/null +++ b/actions/delete-board/schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const DeleteBoard = z.object({ + id: z.string(), +}) \ No newline at end of file diff --git a/actions/delete-board/types.ts b/actions/delete-board/types.ts new file mode 100644 index 0000000..b980ff3 --- /dev/null +++ b/actions/delete-board/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { Board } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { DeleteBoard } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/actions/delete-card/index.ts b/actions/delete-card/index.ts new file mode 100644 index 0000000..7b5dab2 --- /dev/null +++ b/actions/delete-card/index.ts @@ -0,0 +1,50 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { DeleteCard } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { id, boardId } = data; + let card; + + try { + card = await db.card.delete({ + where: { + id, + list: { + board: { + orgId, + }, + }, + }, + }); + + await createAuditLog({ + entityTitle: card.title, + entityType: ENTITY_TYPE.CARD, + entityId: card.id, + action: ACTION.DELETE, + }); + } catch (error) { + return { + error: "Failed to delete card", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: card }; +}; + +export const deleteCard = createSafeAction(DeleteCard, handler); diff --git a/actions/delete-card/schema.ts b/actions/delete-card/schema.ts new file mode 100644 index 0000000..6eed7e4 --- /dev/null +++ b/actions/delete-card/schema.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const DeleteCard = z.object({ + id: z.string(), + boardId: z.string(), +}); diff --git a/actions/delete-card/types.ts b/actions/delete-card/types.ts new file mode 100644 index 0000000..e405f83 --- /dev/null +++ b/actions/delete-card/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { Card } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { DeleteCard } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; diff --git a/actions/delete-list/index.ts b/actions/delete-list/index.ts new file mode 100644 index 0000000..973216e --- /dev/null +++ b/actions/delete-list/index.ts @@ -0,0 +1,49 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { DeleteList } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { id, boardId } = data; + let list; + + try { + list = await db.list.delete({ + where: { + id, + boardId, + board: { + orgId, + }, + }, + }); + + await createAuditLog({ + entityTitle: list.title, + entityType: ENTITY_TYPE.LIST, + entityId: list.id, + action: ACTION.DELETE, + }); + } catch (error) { + return { + error: "Failed to delete list", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: list }; +}; + +export const deleteList = createSafeAction(DeleteList, handler); diff --git a/actions/delete-list/schema.ts b/actions/delete-list/schema.ts new file mode 100644 index 0000000..48abbb1 --- /dev/null +++ b/actions/delete-list/schema.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const DeleteList = z.object({ + id: z.string(), + boardId: z.string(), +}) \ No newline at end of file diff --git a/actions/delete-list/types.ts b/actions/delete-list/types.ts new file mode 100644 index 0000000..6d40a6b --- /dev/null +++ b/actions/delete-list/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { List } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { DeleteList } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/actions/stripe-redirect/index.ts b/actions/stripe-redirect/index.ts new file mode 100644 index 0000000..ac6dcd9 --- /dev/null +++ b/actions/stripe-redirect/index.ts @@ -0,0 +1,75 @@ +"use server"; + +import { auth, currentUser } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; + +import { db } from "@/lib/db"; +import { createSafeAction } from "@/lib/create-safe-action"; +import { absoluteUrl } from "@/lib/utils"; +import { stripe } from "@/lib/stripe"; + +import { InputType, ReturnType } from "./types"; +import { StripeRedirect } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + const user = await currentUser(); + + if (!userId || !orgId || !user) return { error: "Unauthorized" }; + + const settingsUrl = absoluteUrl(`/organization/${orgId}`); + + let url = ""; + + try { + const orgSubscription = await db.orgSubscription.findUnique({ + where: { orgId }, + }); + + if (orgSubscription?.stripeCustomerId) { + const stripeSession = await stripe.billingPortal.sessions.create({ + customer: orgSubscription.stripeCustomerId, + return_url: settingsUrl, + }); + + url = stripeSession.url; + } else { + const stripeSession = await stripe.checkout.sessions.create({ + success_url: settingsUrl, + cancel_url: settingsUrl, + payment_method_types: ["card", "paypal"], + mode: "subscription", + billing_address_collection: "auto", + customer_email: user.emailAddresses[0].emailAddress, + line_items: [ + { + price_data: { + currency: "usd", + product_data: { + name: "Tasko Pro", + description: "Unlimited boards for your organization", + }, + unit_amount: 2000, + recurring: { interval: "month" }, + }, + quantity: 1, + }, + ], + metadata: { + orgId, + }, + }); + + url = stripeSession.url ?? ""; + } + } catch (error) { + return { + error: "Something went wrong", + }; + } + + revalidatePath(`/organization/${orgId}`); + return { data: url }; +}; + +export const stripeRedirect = createSafeAction(StripeRedirect, handler); diff --git a/actions/stripe-redirect/schema.ts b/actions/stripe-redirect/schema.ts new file mode 100644 index 0000000..0e8549d --- /dev/null +++ b/actions/stripe-redirect/schema.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const StripeRedirect = z.object({}); diff --git a/actions/stripe-redirect/types.ts b/actions/stripe-redirect/types.ts new file mode 100644 index 0000000..3752693 --- /dev/null +++ b/actions/stripe-redirect/types.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { StripeRedirect } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; diff --git a/actions/update-board/index.ts b/actions/update-board/index.ts new file mode 100644 index 0000000..9d48c4c --- /dev/null +++ b/actions/update-board/index.ts @@ -0,0 +1,49 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { UpdateBoard } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { title, id } = data; + let board; + + try { + board = await db.board.update({ + where: { + id, + orgId, + }, + data: { + title, + }, + }); + + await createAuditLog({ + entityTitle: board.title, + entityType: ENTITY_TYPE.BOARD, + entityId: board.id, + action: ACTION.UPDATE, + }); + } catch (error) { + return { + error: "Failed to update board", + }; + } + + revalidatePath(`/board/${id}`); + return { data: board }; +}; + +export const updateBoard = createSafeAction(UpdateBoard, handler); \ No newline at end of file diff --git a/actions/update-board/schema.ts b/actions/update-board/schema.ts new file mode 100644 index 0000000..731bf90 --- /dev/null +++ b/actions/update-board/schema.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const UpdateBoard = z.object({ + title: z.string({ + required_error: "Title is required", + invalid_type_error: "Title must be a string", + }).min(3, { + message: "Title must be at least 3 characters", + }), + id: z.string(), +}) \ No newline at end of file diff --git a/actions/update-board/types.ts b/actions/update-board/types.ts new file mode 100644 index 0000000..e8e23b2 --- /dev/null +++ b/actions/update-board/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { Board } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { UpdateBoard } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/actions/update-card-order/index.ts b/actions/update-card-order/index.ts new file mode 100644 index 0000000..323afaf --- /dev/null +++ b/actions/update-card-order/index.ts @@ -0,0 +1,49 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; + +import { db } from "@/lib/db"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { UpdateCardOrder } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { items, boardId } = data; + let updatedCards; + + try { + const transaction = items.map((card) => + db.card.update({ + where: { + id: card.id, + list: { + board: { + orgId, + }, + }, + }, + data: { + order: card.order, + listId: card.listId, + }, + }) + ); + + updatedCards = await db.$transaction(transaction); + } catch (error) { + return { + error: "Failed to reorder list", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: updatedCards }; +}; + +export const updateCardOrder = createSafeAction(UpdateCardOrder, handler); diff --git a/actions/update-card-order/schema.ts b/actions/update-card-order/schema.ts new file mode 100644 index 0000000..fba6d89 --- /dev/null +++ b/actions/update-card-order/schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const UpdateCardOrder = z.object({ + items: z.array( + z.object({ + id: z.string(), + title: z.string(), + order: z.number(), + listId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + }) + ), + boardId: z.string(), +}); diff --git a/actions/update-card-order/types.ts b/actions/update-card-order/types.ts new file mode 100644 index 0000000..d10877d --- /dev/null +++ b/actions/update-card-order/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { Card } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { UpdateCardOrder } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/actions/update-card/index.ts b/actions/update-card/index.ts new file mode 100644 index 0000000..270fced --- /dev/null +++ b/actions/update-card/index.ts @@ -0,0 +1,53 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { UpdateCard } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { id, boardId, ...values } = data; + let card; + + try { + card = await db.card.update({ + where: { + id, + list: { + board: { + orgId, + }, + }, + }, + data: { + ...values, + }, + }); + + await createAuditLog({ + entityTitle: card.title, + entityType: ENTITY_TYPE.CARD, + entityId: card.id, + action: ACTION.UPDATE, + }); + } catch (error) { + return { + error: "Failed to update card", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: card }; +}; + +export const updateCard = createSafeAction(UpdateCard, handler); diff --git a/actions/update-card/schema.ts b/actions/update-card/schema.ts new file mode 100644 index 0000000..d4282b3 --- /dev/null +++ b/actions/update-card/schema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const UpdateCard = z.object({ + boardId: z.string(), + description: z.optional( + z + .string({ + invalid_type_error: "Description must be a string", + required_error: "Description is required", + }) + .min(3, { + message: "Description must be at least 3 characters", + }) + ), + title: z.optional( + z + .string({ + required_error: "Title is required", + invalid_type_error: "Title must be a string", + }) + .min(3, { + message: "Title must be at least 3 characters", + }) + ), + id: z.string(), +}); diff --git a/actions/update-card/types.ts b/actions/update-card/types.ts new file mode 100644 index 0000000..f15794c --- /dev/null +++ b/actions/update-card/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { Card } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { UpdateCard } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/actions/update-list-order/index.ts b/actions/update-list-order/index.ts new file mode 100644 index 0000000..6254340 --- /dev/null +++ b/actions/update-list-order/index.ts @@ -0,0 +1,46 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; + +import { db } from "@/lib/db"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { UpdateListOrder } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { items, boardId } = data; + let lists; + + try { + const transaction = items.map((list) => + db.list.update({ + where: { + id: list.id, + board: { + orgId, + }, + }, + data: { + order: list.order, + }, + }) + ); + + lists = await db.$transaction(transaction); + } catch (error) { + return { + error: "Failed to reorder list", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: lists }; +}; + +export const updateListOrder = createSafeAction(UpdateListOrder, handler); diff --git a/actions/update-list-order/schema.ts b/actions/update-list-order/schema.ts new file mode 100644 index 0000000..be2e5ca --- /dev/null +++ b/actions/update-list-order/schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const UpdateListOrder = z.object({ + items: z.array( + z.object({ + id: z.string(), + title: z.string(), + order: z.number(), + createdAt: z.date(), + updatedAt: z.date(), + }) + ), + boardId: z.string(), +}); diff --git a/actions/update-list-order/types.ts b/actions/update-list-order/types.ts new file mode 100644 index 0000000..6743566 --- /dev/null +++ b/actions/update-list-order/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { List } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { UpdateListOrder } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/actions/update-list/index.ts b/actions/update-list/index.ts new file mode 100644 index 0000000..06da37c --- /dev/null +++ b/actions/update-list/index.ts @@ -0,0 +1,52 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { InputType, ReturnType } from "./types"; +import { UpdateList } from "./schema"; + +const handler = async (data: InputType): Promise => { + const { userId, orgId } = auth(); + + if (!userId || !orgId) return { error: "Unauthorized" }; + + const { title, id, boardId } = data; + let list; + + try { + list = await db.list.update({ + where: { + id, + boardId, + board: { + orgId, + }, + }, + data: { + title, + }, + }); + + await createAuditLog({ + entityTitle: list.title, + entityType: ENTITY_TYPE.LIST, + entityId: list.id, + action: ACTION.UPDATE, + }); + } catch (error) { + return { + error: "Failed to update list", + }; + } + + revalidatePath(`/board/${boardId}`); + return { data: list }; +}; + +export const updateList = createSafeAction(UpdateList, handler); diff --git a/actions/update-list/schema.ts b/actions/update-list/schema.ts new file mode 100644 index 0000000..6724fa2 --- /dev/null +++ b/actions/update-list/schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const UpdateList = z.object({ + title: z.string({ + required_error: "Title is required", + invalid_type_error: "Title must be a string", + }).min(2, { + message: "Title must be at least 2 characters", + }), + id: z.string(), + boardId: z.string(), +}) \ No newline at end of file diff --git a/actions/update-list/types.ts b/actions/update-list/types.ts new file mode 100644 index 0000000..fed4d6e --- /dev/null +++ b/actions/update-list/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { List } from "@prisma/client"; + +import { ActionState } from "@/lib/create-safe-action"; + +import { UpdateList } from "./schema"; + +export type InputType = z.infer; +export type ReturnType = ActionState; \ No newline at end of file diff --git a/app/(marketing)/_components/footer.tsx b/app/(marketing)/_components/footer.tsx new file mode 100644 index 0000000..4b0b53a --- /dev/null +++ b/app/(marketing)/_components/footer.tsx @@ -0,0 +1,20 @@ +import { Logo } from "@/components/logo"; +import { Button } from "@/components/ui/button"; + +export const Footer = () => { + return ( +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/app/(marketing)/_components/navbar.tsx b/app/(marketing)/_components/navbar.tsx new file mode 100644 index 0000000..0ae9724 --- /dev/null +++ b/app/(marketing)/_components/navbar.tsx @@ -0,0 +1,21 @@ +import { Logo } from "@/components/logo"; +import { Button } from "@/components/ui/button"; +import Link from "next/link"; + +export const Navbar = () => { + return ( +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..6dbe428 --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,14 @@ +import { Footer } from "./_components/footer"; +import { Navbar } from "./_components/navbar"; + +const MarketingLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
+ +
{children}
+
+
+ ); +}; + +export default MarketingLayout; diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx new file mode 100644 index 0000000..7b73068 --- /dev/null +++ b/app/(marketing)/page.tsx @@ -0,0 +1,54 @@ +import { Medal } from "lucide-react"; +import localFont from "next/font/local"; +import { Poppins } from "next/font/google"; +import Link from "next/link"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import Image from "next/image"; + +const headingFont = localFont({ src: "../../public/fonts/font.woff2" }); + +const textFont = Poppins({ + subsets: ["latin"], + weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], +}); + +const MarketingPage = () => { + return ( +
+
+
+ + No 1 task management app +
+

+ Tasko helps teams move +

+
+ Work forward +
+
+
+ Collaborate, manage projects, and reach new productivity peaks. From + high rises to the home office, the way your team works is unique - + accomplish it all with Tasko. +
+ +
+ ); +}; + +export default MarketingPage; diff --git a/app/(platform)/(clerk)/layout.tsx b/app/(platform)/(clerk)/layout.tsx new file mode 100644 index 0000000..9a14330 --- /dev/null +++ b/app/(platform)/(clerk)/layout.tsx @@ -0,0 +1,9 @@ +const ClerkLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
+ {children} +
+ ) +} + +export default ClerkLayout; \ No newline at end of file diff --git a/app/(platform)/(clerk)/select-org/[[...select-org]]/page.tsx b/app/(platform)/(clerk)/select-org/[[...select-org]]/page.tsx new file mode 100644 index 0000000..cdc397c --- /dev/null +++ b/app/(platform)/(clerk)/select-org/[[...select-org]]/page.tsx @@ -0,0 +1,11 @@ +import { OrganizationList } from "@clerk/nextjs"; + +export default function CreateOrganizationPage() { + return ( + + ); +} diff --git a/app/(platform)/(clerk)/sign-in/[[...signin]]/page.tsx b/app/(platform)/(clerk)/sign-in/[[...signin]]/page.tsx new file mode 100644 index 0000000..2cc13d4 --- /dev/null +++ b/app/(platform)/(clerk)/sign-in/[[...signin]]/page.tsx @@ -0,0 +1,5 @@ +import { SignIn } from "@clerk/nextjs"; + +export default function Page() { + return ; +} diff --git a/app/(platform)/(clerk)/sign-up/[[...sign-up]]/page.tsx b/app/(platform)/(clerk)/sign-up/[[...sign-up]]/page.tsx new file mode 100644 index 0000000..2743945 --- /dev/null +++ b/app/(platform)/(clerk)/sign-up/[[...sign-up]]/page.tsx @@ -0,0 +1,5 @@ +import { SignUp } from "@clerk/nextjs"; + +export default function Page() { + return ; +} diff --git a/app/(platform)/(dashboard)/_components/Navbar.tsx b/app/(platform)/(dashboard)/_components/Navbar.tsx new file mode 100644 index 0000000..dd67533 --- /dev/null +++ b/app/(platform)/(dashboard)/_components/Navbar.tsx @@ -0,0 +1,67 @@ +import { Plus } from "lucide-react"; +import { OrganizationSwitcher, UserButton } from "@clerk/nextjs"; + +import { Logo } from "@/components/logo"; +import { Button } from "@/components/ui/button"; +import { FormPopover } from "@/components/form/form-popover"; + +import { MobileSidebar } from "./mobile-sidebar"; + +export const Navbar = () => { + return ( + + ); +}; diff --git a/app/(platform)/(dashboard)/_components/mobile-sidebar.tsx b/app/(platform)/(dashboard)/_components/mobile-sidebar.tsx new file mode 100644 index 0000000..c83f03f --- /dev/null +++ b/app/(platform)/(dashboard)/_components/mobile-sidebar.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Menu } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useMobileSidebar } from "@/hooks/use-mobile-sidebar"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Sheet, SheetContent } from "@/components/ui/sheet"; +import { Sidebar } from "./sidebar"; + +export const MobileSidebar = () => { + const pathname = usePathname(); + const [isMounted, setIsMounted] = useState(false); + + const onOpen = useMobileSidebar((state) => state.onOpen); + const onClose = useMobileSidebar((state) => state.onClose); + const isOpen = useMobileSidebar((state) => state.isOpen); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + onClose(); + }, [pathname, onClose]); + + if (!isMounted) { + return null; + } + + return ( + <> + + + + + + + + ); +}; diff --git a/app/(platform)/(dashboard)/_components/nav-item.tsx b/app/(platform)/(dashboard)/_components/nav-item.tsx new file mode 100644 index 0000000..850d773 --- /dev/null +++ b/app/(platform)/(dashboard)/_components/nav-item.tsx @@ -0,0 +1,117 @@ +"use client"; + +import Image from "next/image"; +import { Activity, CreditCard, Layout, Settings } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; + +import { + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +export type Organization = { + id: string; + slug: string; + imageUrl: string; + name: string; +}; + +interface NavItemsProps { + isExpanded: boolean; + isActive: boolean; + organization: Organization; + onExpand: (id: string) => void; +} + +export const NavItem = ({ + isExpanded, + isActive, + organization, + onExpand, +}: NavItemsProps) => { + const router = useRouter(); + const pathname = usePathname(); + + const routes = [ + { + label: "Boards", + icon: , + href: `/organization/${organization.id}`, + }, + { + label: "Activity", + icon: , + href: `/organization/${organization.id}/activity`, + }, + { + label: "Settings", + icon: , + href: `/organization/${organization.id}/settings`, + }, + { + label: "Billing", + icon: , + href: `/organization/${organization.id}/billing`, + }, + ]; + + const onClick = (href: string) => { + router.push(href); + }; + + return ( + + onExpand(organization.id)} + className={cn( + "flex items-center gap-x-2 p-1.5 text-neutral-700 rounded-md hover:bg-neutral-500/10 transition text-start no-underline hover:no-underline", + isActive && !isExpanded && "bg-sky-500/10 text-sky-700" + )} + > +
+
+ {organization.name} +
+ {organization.name} +
+
+ + {routes.map((route) => ( + + ))} + +
+ ); +}; + +NavItem.Skeleton = function SkeletonNavItem() { + return ( +
+
+ +
+ +
+ ); +}; diff --git a/app/(platform)/(dashboard)/_components/sidebar.tsx b/app/(platform)/(dashboard)/_components/sidebar.tsx new file mode 100644 index 0000000..2464b70 --- /dev/null +++ b/app/(platform)/(dashboard)/_components/sidebar.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Link from "next/link"; +import { Plus } from "lucide-react"; +import { useLocalStorage } from "usehooks-ts"; +import { useOrganizationList, useOrganization } from "@clerk/nextjs"; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Accordion } from "@/components/ui/accordion"; +import { Skeleton } from "@/components/ui/skeleton"; +import { NavItem, Organization } from "./nav-item"; + +interface SidebarProps { + storageKey?: string; +} + +export const Sidebar = ({ storageKey = "t-sidebar-state" }: SidebarProps) => { + const [expanded, setExpanded] = useLocalStorage>( + storageKey, + {} + ); + const { organization: activeOrganization, isLoaded: isLoadedOrg } = + useOrganization(); + const { userMemberships, isLoaded: isLoadedOrgList } = useOrganizationList({ + userMemberships: { infinite: true }, + }); + + const defaultAccordionValue: string[] = Object.keys(expanded).reduce( + (acc: string[], key: string) => { + if (expanded[key]) { + acc.push(key); + } + return acc; + }, + [] + ); + + const onExpand = (id: string) => { + setExpanded((curr) => ({ + ...curr, + [id]: !expanded[id], + })); + }; + + if (!isLoadedOrgList || !isLoadedOrg || userMemberships.isLoading) { + return ( + <> +
+ + +
+
+ + + +
+ + ); + } + + return ( + <> +
+ Workspaces + +
+ + {userMemberships.data.map(({ organization }) => ( + + ))} + + + ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/board-navbar.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/board-navbar.tsx new file mode 100644 index 0000000..582b776 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/board-navbar.tsx @@ -0,0 +1,19 @@ +import { Board } from "@prisma/client"; + +import { BoardTitleForm } from "./board-title-form"; +import { BoardOptions } from "./board-options"; + +interface BoardNavbarProps { + data: Board; +} + +export const BoardNavbar = async ({ data }: BoardNavbarProps) => { + return ( +
+ +
+ +
+
+ ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/board-options.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/board-options.tsx new file mode 100644 index 0000000..47fd8bc --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/board-options.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { MoreHorizontal, X } from "lucide-react"; +import { toast } from "sonner"; + +import { deleteBoard } from "@/actions/delete-board"; +import { useAction } from "@/hooks/use-action"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +interface BoardOptionsProps { + id: string; +} + +export const BoardOptions = ({ id }: BoardOptionsProps) => { + const { execute, isLoading } = useAction(deleteBoard, { + onError: (error) => { + toast.error(error); + }, + }); + + const onDelete = () => { + execute({ id }); + }; + + return ( + + + + + +
+ Board Actions +
+ + + + +
+
+ ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/board-title-form.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/board-title-form.tsx new file mode 100644 index 0000000..649116c --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/board-title-form.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { toast } from "sonner"; +import { ElementRef, useRef, useState } from "react"; +import { Board } from "@prisma/client"; + +import { Button } from "@/components/ui/button"; +import { FormInput } from "@/components/form/form-input"; +import { updateBoard } from "@/actions/update-board"; +import { useAction } from "@/hooks/use-action"; + +interface BoardTitleFormProps { + data: Board; +} + +export const BoardTitleForm = ({ data }: BoardTitleFormProps) => { + const { execute } = useAction(updateBoard, { + onSuccess: (data) => { + toast.success(`Board "${data.title}" updated!`); + setTitle(data.title); + disableEditing(); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const formRef = useRef>(null); + const inputRef = useRef>(null); + + const [title, setTitle] = useState(data.title); + const [isEditing, setIsEditing] = useState(false); + + const enableEditing = () => { + setIsEditing(true); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }; + + const disableEditing = () => { + setIsEditing(false); + }; + + const onSubmit = (formData: FormData) => { + const title = formData.get("title") as string; + + execute({ + title, + id: data.id, + }); + }; + + const onBlur = () => { + formRef.current?.requestSubmit(); + }; + + if (isEditing) { + return ( +
+ + + ); + } + + return ( + + ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/card-form.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/card-form.tsx new file mode 100644 index 0000000..8983bba --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/card-form.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Plus, X } from "lucide-react"; +import { forwardRef, useRef, ElementRef, KeyboardEventHandler } from "react"; +import { useOnClickOutside, useEventListener } from "usehooks-ts"; +import { useParams } from "next/navigation"; +import { toast } from "sonner"; + +import { useAction } from "@/hooks/use-action"; +import { createCard } from "@/actions/create-card"; +import { Button } from "@/components/ui/button"; +import { FormTextarea } from "@/components/form/form-textarea"; +import { FormSubmit } from "@/components/form/form-submit"; + +interface CardFormProps { + listId: string; + isEditing: boolean; + enableEditing: () => void; + disableEditing: () => void; +} + +export const CardForm = forwardRef( + ({ listId, isEditing, enableEditing, disableEditing }, ref) => { + const params = useParams(); + const formRef = useRef>(null); + + const { execute, fieldErrors } = useAction(createCard, { + onSuccess: (data) => { + toast.success(`Card "${data.title}" created`); + formRef.current?.reset(); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + disableEditing(); + } + }; + + useOnClickOutside(formRef, disableEditing); + useEventListener("keydown", onKeyDown); + + const onTextareaKeyDown: KeyboardEventHandler = ( + e + ) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + formRef.current?.requestSubmit(); + } + }; + + const onSubmit = (formData: FormData) => { + const title = formData.get("title") as string; + const listId = formData.get("listId") as string; + const boardId = params.boardId as string; + + execute({ title, listId, boardId }); + }; + + if (isEditing) { + return ( +
+ + +
+ Add card + +
+ + ); + } + + return ( +
+ +
+ ); + } +); + +CardForm.displayName = "CardForm"; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/card-item.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/card-item.tsx new file mode 100644 index 0000000..d191898 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/card-item.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Draggable } from "@hello-pangea/dnd"; +import { Card } from "@prisma/client"; + +import { useCardModal } from "@/hooks/use-card-modal"; + +interface CardItemProps { + index: number; + data: Card; +} + +export const CardItem = ({ index, data }: CardItemProps) => { + const cardModal = useCardModal(); + + return ( + + {(provided) => ( +
cardModal.onOpen(data.id)} + className="truncate border-2 border-transparent hover:border-black py-2 px-3 text-sm bg-white rounded-md shadow-sm" + > + {data.title} +
+ )} +
+ ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/list-container.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/list-container.tsx new file mode 100644 index 0000000..c009738 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-container.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { DragDropContext, Droppable } from "@hello-pangea/dnd"; +import { toast } from "sonner"; + +import { useAction } from "@/hooks/use-action"; +import { updateListOrder } from "@/actions/update-list-order"; +import { updateCardOrder } from "@/actions/update-card-order"; +import { ListWithCards } from "@/types"; + +import { ListForm } from "./list-form"; +import { ListItem } from "./list-item"; + +interface ListContainerProps { + data: ListWithCards[]; + boardId: string; +} + +function reorder(list: T[], startIndex: number, endIndex: number) { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +} + +export const ListContainer = ({ data, boardId }: ListContainerProps) => { + const [orderedData, setOrderedData] = useState(data); + + const { execute: executeUpdateListOrder } = useAction(updateListOrder, { + onSuccess: () => { + toast.success("List reordered"); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const { execute: executeUpdateCardOrder } = useAction(updateCardOrder, { + onSuccess: () => { + toast.success("Card reordered"); + }, + onError: (error) => { + toast.error(error); + }, + }); + + useEffect(() => { + setOrderedData(data); + }, [data]); + + const onDragEnd = (result: any) => { + const { destination, source, type } = result; + + if (!destination) return; + + // User drops the item in the same position + if ( + destination.droppableId === source.droppableId && + destination.index === source.index + ) + return; + + // User moves a list + if (type === "list") { + const items = reorder(orderedData, source.index, destination.index).map( + (item, index) => ({ ...item, order: index }) + ); + setOrderedData(items); + executeUpdateListOrder({ items, boardId }); + } + + // User moves a card + if (type === "card") { + let newOrderedData = [...orderedData]; + + // Get source and destination list + const sourceList = newOrderedData.find( + (list) => list.id === source.droppableId + ); + const destinationList = newOrderedData.find( + (list) => list.id === destination.droppableId + ); + + if (!sourceList || !destinationList) return; + + // Check if card exists on the sourceList + if (!sourceList.cards) sourceList.cards = []; + + // Check if card exists on the destinationList + if (!destinationList.cards) destinationList.cards = []; + + // Moving the card in the same list + if (source.droppableId === destination.droppableId) { + const reorderedCards = reorder( + sourceList.cards, + source.index, + destination.index + ); + + reorderedCards.forEach((card, index) => { + card.order = index; + }); + + sourceList.cards = reorderedCards; + + setOrderedData(newOrderedData); + executeUpdateCardOrder({ + boardId, + items: reorderedCards, + }); + } else { + // Moving the card from one list to another + + // Remove card from source list + const [movedCard] = sourceList.cards.splice(source.index, 1); + + // Assign the new listId to the moved card + movedCard.listId = destination.droppableId; + + // Add the card to the destination list + destinationList.cards.splice(destination.index, 0, movedCard); + + sourceList.cards.forEach((card, index) => { + card.order = index; + }); + + // Update the order for each card in the destination list + destinationList.cards.forEach((card, index) => { + card.order = index; + }); + + setOrderedData(newOrderedData); + executeUpdateCardOrder({ + boardId, + items: destinationList.cards, + }); + } + } + }; + + return ( + + + {(provided) => ( +
    + {orderedData.map((list, index) => { + return ; + })} + {provided.placeholder} + +
    +
+ )} +
+
+ ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/list-form.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/list-form.tsx new file mode 100644 index 0000000..9e00b2f --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-form.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Plus, X } from "lucide-react"; +import { useState, useRef, ElementRef } from "react"; +import { useEventListener, useOnClickOutside } from "usehooks-ts"; +import { useParams, useRouter } from "next/navigation"; +import { toast } from "sonner"; + +import { FormInput } from "@/components/form/form-input"; +import { FormSubmit } from "@/components/form/form-submit"; +import { Button } from "@/components/ui/button"; +import { useAction } from "@/hooks/use-action"; +import { createList } from "@/actions/create-list"; + +import { ListWrapper } from "./list-wrapper"; + +export const ListForm = () => { + const router = useRouter(); + const params = useParams(); + + const formRef = useRef>(null); + const inputRef = useRef>(null); + + const [isEditing, setIsEditing] = useState(false); + + const enableEditing = () => { + setIsEditing(true); + setTimeout(() => { + inputRef.current?.focus(); + }); + }; + + const disableEditing = () => { + setIsEditing(false); + }; + + const { execute, fieldErrors } = useAction(createList, { + onSuccess: (data) => { + toast.success(`List "${data.title}" created!`); + disableEditing(); + router.refresh(); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + disableEditing(); + } + }; + + useEventListener("keydown", onKeyDown); + useOnClickOutside(formRef, disableEditing); + + const onSubmit = (formData: FormData) => { + const title = formData.get("title") as string; + const boardId = formData.get("boardId") as string; + + execute({ title, boardId }); + }; + + if (isEditing) { + return ( + +
+ + +
+ Add list + +
+ +
+ ); + } + + return ( + + + + ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/list-header.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/list-header.tsx new file mode 100644 index 0000000..c2b2edc --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-header.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState, useRef, ElementRef } from "react"; +import { useEventListener } from "usehooks-ts"; +import { List } from "@prisma/client"; +import { toast } from "sonner"; + +import { FormInput } from "@/components/form/form-input"; +import { useAction } from "@/hooks/use-action"; +import { updateList } from "@/actions/update-list"; + +import { ListOptions } from "./list-options"; + +interface ListHeaderProps { + data: List; + onAddCard: () => void; +} + +export const ListHeader = ({ data, onAddCard }: ListHeaderProps) => { + const [title, setTitle] = useState(data.title); + const [isEditing, setIsEditing] = useState(false); + + const formRef = useRef>(null); + const inputRef = useRef>(null); + + const enableEditing = () => { + setIsEditing(true); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + }; + + const disableEditing = () => { + setIsEditing(false); + }; + + const { execute } = useAction(updateList, { + onSuccess: (data) => { + toast.success(`Renamed to "${data.title}"`); + setTitle(data.title); + disableEditing(); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const onSubmit = (formData: FormData) => { + const title = formData.get("title") as string; + const id = formData.get("id") as string; + const boardId = formData.get("boardId") as string; + + if (title === data.title) return disableEditing(); + + execute({ title, id, boardId }); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + formRef.current?.requestSubmit(); + } + }; + + const onBlur = () => { + formRef.current?.requestSubmit(); + }; + + useEventListener("keydown", onKeyDown); + + return ( +
+ {isEditing ? ( +
+ + + +
+ ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/list-item.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/list-item.tsx new file mode 100644 index 0000000..64980dc --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-item.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { ElementRef, useRef, useState } from "react"; +import { Draggable, Droppable } from "@hello-pangea/dnd"; + +import { ListWithCards } from "@/types"; +import { cn } from "@/lib/utils"; + +import { ListHeader } from "./list-header"; +import { CardForm } from "./card-form"; +import { CardItem } from "./card-item"; + +interface ListItemProps { + data: ListWithCards; + index: number; +} + +export const ListItem = ({ index, data }: ListItemProps) => { + const textareaRef = useRef>(null); + + const [isEditing, setIsEditing] = useState(false); + + const disableEditing = () => { + setIsEditing(false); + }; + + const enableEditing = () => { + setIsEditing(true); + setTimeout(() => { + textareaRef.current?.focus(); + }); + }; + + return ( + + {(provided) => ( +
  • +
    + + + {(provided) => ( +
      0 ? "mt-2" : "mt-0" + )} + > + {data.cards.map((card, index) => ( + + ))} + {provided.placeholder} +
    + )} +
    + +
    +
  • + )} +
    + ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/list-options.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/list-options.tsx new file mode 100644 index 0000000..5103820 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-options.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { MoreHorizontal, X } from "lucide-react"; +import { ElementRef, useRef } from "react"; +import { toast } from "sonner"; +import { List } from "@prisma/client"; + +import { + Popover, + PopoverContent, + PopoverTrigger, + PopoverClose, +} from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { FormSubmit } from "@/components/form/form-submit"; +import { Separator } from "@/components/ui/separator"; +import { useAction } from "@/hooks/use-action"; +import { deleteList } from "@/actions/delete-list"; +import { copyList } from "@/actions/copy-list"; + +interface ListOptionsProps { + data: List; + onAddCard: () => void; +} + +export const ListOptions = ({ data, onAddCard }: ListOptionsProps) => { + const closeRef = useRef>(null); + + const { execute: executeDelete } = useAction(deleteList, { + onSuccess: () => { + toast.success(`List "${data.title}" deleted`); + closeRef.current?.click(); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const { execute: executeCopy } = useAction(copyList, { + onSuccess: () => { + toast.success(`List "${data.title}" copied`); + closeRef.current?.click(); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const onDelete = (formData: FormData) => { + const id = formData.get("id") as string; + const boardId = formData.get("boardId") as string; + + executeDelete({ id, boardId }); + }; + + const onCopy = (formData: FormData) => { + const id = formData.get("id") as string; + const boardId = formData.get("boardId") as string; + + executeCopy({ id, boardId }); + }; + + return ( + + + + + +
    + List Actions +
    + + + + +
    + + + + Copy list... + +
    + +
    + + + + Delete this list + +
    +
    +
    + ); +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/_components/list-wrapper.tsx b/app/(platform)/(dashboard)/board/[boardId]/_components/list-wrapper.tsx new file mode 100644 index 0000000..a5d20b5 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-wrapper.tsx @@ -0,0 +1,7 @@ +interface ListWrapperProps { + children: React.ReactNode; +} + +export const ListWrapper = ({ children }: ListWrapperProps) => { + return
  • {children}
  • ; +}; diff --git a/app/(platform)/(dashboard)/board/[boardId]/layout.tsx b/app/(platform)/(dashboard)/board/[boardId]/layout.tsx new file mode 100644 index 0000000..f2530a8 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/layout.tsx @@ -0,0 +1,60 @@ +import { auth } from "@clerk/nextjs"; +import { notFound, redirect } from "next/navigation"; + +import { db } from "@/lib/db"; +import { BoardNavbar } from "./_components/board-navbar"; + +export async function generateMetadata({ + params, +}: { + params: { boardId: string }; +}) { + const { orgId } = auth(); + + if (!orgId) return { title: "Board" }; + + const board = await db.board.findUnique({ + where: { + id: params.boardId, + orgId, + }, + }); + + return { + title: board?.title ?? "Board", + }; +} + +const BoardIdLayout = async ({ + children, + params, +}: { + children: React.ReactNode; + params: { boardId: string }; +}) => { + const { orgId } = auth(); + + if (!orgId) redirect("/select-org"); + + const board = await db.board.findUnique({ + where: { + id: params.boardId, + orgId, + }, + }); + + if (!board) notFound(); + + return ( +
    + +
    +
    {children}
    +
    + ); +}; + +export default BoardIdLayout; diff --git a/app/(platform)/(dashboard)/board/[boardId]/page.tsx b/app/(platform)/(dashboard)/board/[boardId]/page.tsx new file mode 100644 index 0000000..01aa505 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/page.tsx @@ -0,0 +1,46 @@ +import { auth } from "@clerk/nextjs"; +import { redirect } from "next/navigation"; + +import { db } from "@/lib/db"; +import { ListContainer } from "./_components/list-container"; + +interface BoardIdPageProps { + params: { + boardId: string; + }; +} + +const BoardIdPage = async ({ params }: BoardIdPageProps) => { + const { orgId } = auth(); + + if (!orgId) { + redirect("/select-org"); + } + + const lists = await db.list.findMany({ + where: { + boardId: params.boardId, + board: { + orgId, + }, + }, + include: { + cards: { + orderBy: { + order: "asc", + }, + }, + }, + orderBy: { + order: "asc", + }, + }); + + return ( +
    + +
    + ) +}; + +export default BoardIdPage; diff --git a/app/(platform)/(dashboard)/layout.tsx b/app/(platform)/(dashboard)/layout.tsx new file mode 100644 index 0000000..bd69b6b --- /dev/null +++ b/app/(platform)/(dashboard)/layout.tsx @@ -0,0 +1,12 @@ +import { Navbar } from "./_components/Navbar"; + +const DashbordLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
    + + {children} +
    + ); +}; + +export default DashbordLayout; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/_components/board-list.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/_components/board-list.tsx new file mode 100644 index 0000000..d848c83 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/_components/board-list.tsx @@ -0,0 +1,90 @@ +import Link from "next/link"; +import { auth } from "@clerk/nextjs"; +import { redirect } from "next/navigation"; +import { HelpCircle, User2 } from "lucide-react"; + +import { db } from "@/lib/db"; +import { Hint } from "@/components/hint"; +import { Skeleton } from "@/components/ui/skeleton"; +import { FormPopover } from "@/components/form/form-popover"; +import { MAX_FREE_BOARDS } from "@/constants/boards"; +import { getAvailableCount } from "@/lib/org-limit"; +import { checkSubscription } from "@/lib/subscription"; + +export const BoardList = async () => { + const { orgId } = auth(); + + if (!orgId) { + return redirect("/select-org"); + } + + const boards = await db.board.findMany({ + where: { + orgId, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const availableCount = await getAvailableCount(); + const isPro = await checkSubscription(); + + return ( +
    +
    + + Your boards +
    +
    + {boards.map((board) => ( + +
    +

    {board.title}

    + + ))} + +
    +

    Create new board

    + + {isPro + ? "Unlimited" + : `${MAX_FREE_BOARDS - availableCount} remaining`} + + + + +
    +
    +
    +
    + ); +}; + +BoardList.Skeleton = function SkeletonBoardList() { + return ( +
    + + + + + + + + +
    + ); +}; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/_components/info.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/_components/info.tsx new file mode 100644 index 0000000..fbcaab7 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/_components/info.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Image from "next/image"; +import { CreditCard } from "lucide-react"; + +import { useOrganization } from "@clerk/nextjs"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface InfoProps { + isPro: boolean; +} + +export const Info = ({ isPro }: InfoProps) => { + const { organization, isLoaded } = useOrganization(); + + if (!isLoaded) { + return ; + } + + return ( +
    +
    + Organization Logo +
    +
    +

    {organization?.name}

    +
    + + {isPro ? "Pro" : "Free"} +
    +
    +
    + ); +}; + +Info.Skeleton = function SkeletonInfo() { + return ( +
    +
    + +
    +
    + +
    + + +
    +
    +
    + ); +}; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/_components/org-control.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/_components/org-control.tsx new file mode 100644 index 0000000..79946f7 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/_components/org-control.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useEffect } from "react"; +import { useParams } from "next/navigation"; +import { useOrganizationList } from "@clerk/nextjs"; + +export const OrgControl = () => { + const params = useParams(); + const { setActive } = useOrganizationList(); + + useEffect(() => { + if (!setActive) return; + + setActive({ + organization: params.organizationId as string, + }); + }, [setActive, params.organizationId]); + + return null; +}; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/activity/_components/activity-list.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/activity/_components/activity-list.tsx new file mode 100644 index 0000000..1bf6f13 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/activity/_components/activity-list.tsx @@ -0,0 +1,44 @@ +import { auth } from "@clerk/nextjs"; +import { redirect } from "next/navigation"; + +import { db } from "@/lib/db"; +import { ActivityItem } from "@/components/activity-item"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const ActivityList = async () => { + const { orgId } = auth(); + + if (!orgId) redirect("/select-org"); + + const auditLogs = await db.auditLog.findMany({ + where: { + orgId, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return ( +
      +

      + No activity found inside this organization +

      + {auditLogs.map((log) => ( + + ))} +
    + ); +}; + +ActivityList.Skeleton = function ActivityListSkeleton() { + return ( +
      + + + + + +
    + ); +}; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/activity/page.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/activity/page.tsx new file mode 100644 index 0000000..59a47a2 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/activity/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { Separator } from "@/components/ui/separator"; + +import { Info } from "../_components/info"; +import { ActivityList } from "./_components/activity-list"; +import { checkSubscription } from "@/lib/subscription"; + +const ActivityPage = async () => { + const isPro = await checkSubscription(); + + return ( +
    + + + }> + + +
    + ); +}; + +export default ActivityPage; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/billing/_components/subscription-button.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/billing/_components/subscription-button.tsx new file mode 100644 index 0000000..57e1b53 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/billing/_components/subscription-button.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { toast } from "sonner"; + +import { stripeRedirect } from "@/actions/stripe-redirect"; +import { Button } from "@/components/ui/button"; +import { useAction } from "@/hooks/use-action"; +import { useProModal } from "@/hooks/use-pro-modal"; + +interface SubscriptionButtonProps { + isPro: boolean; +} + +export const SubscriptionButton = ({ isPro }: SubscriptionButtonProps) => { + const proModal = useProModal(); + + const { execute, isLoading } = useAction(stripeRedirect, { + onSuccess: (data) => { + window.location.href = data; + }, + onError: (error) => { + toast.error(error); + }, + }); + + const onClick = () => { + if (isPro) { + execute({}); + } else { + proModal.onOpen(); + } + }; + + return ( + + ); +}; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/billing/page.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/billing/page.tsx new file mode 100644 index 0000000..1541b11 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/billing/page.tsx @@ -0,0 +1,19 @@ +import { checkSubscription } from "@/lib/subscription"; +import { Separator } from "@/components/ui/separator"; + +import { Info } from "../_components/info"; +import { SubscriptionButton } from "./_components/subscription-button"; + +const BillingPage = async () => { + const isPro = await checkSubscription(); + + return ( +
    + + + +
    + ); +}; + +export default BillingPage; \ No newline at end of file diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/layout.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/layout.tsx new file mode 100644 index 0000000..2ed1aa9 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/layout.tsx @@ -0,0 +1,23 @@ +import { startCase } from "lodash"; +import { auth } from "@clerk/nextjs"; + +import { OrgControl } from "./_components/org-control"; + +export async function generateMetadata() { + const { orgSlug } = auth(); + + return { + title: startCase(orgSlug ?? "organization"), + }; +} + +const OrganizationIdLayout = ({ children }: { children: React.ReactNode }) => { + return ( + <> + + {children} + + ); +}; + +export default OrganizationIdLayout; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/page.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/page.tsx new file mode 100644 index 0000000..1e6bbbe --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/page.tsx @@ -0,0 +1,25 @@ +import { Suspense } from "react"; + +import { Separator } from "@/components/ui/separator"; +import { checkSubscription } from "@/lib/subscription"; + +import { Info } from "./_components/info"; +import { BoardList } from "./_components/board-list"; + +const OrganizationIdPage = async () => { + const isPro = await checkSubscription(); + + return ( +
    + + +
    + }> + + +
    +
    + ); +}; + +export default OrganizationIdPage; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/settings/page.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/settings/page.tsx new file mode 100644 index 0000000..d05c1f0 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/settings/page.tsx @@ -0,0 +1,25 @@ +import { OrganizationProfile } from "@clerk/nextjs"; + +const SettingsPage = () => { + return ( +
    + +
    + ); +}; + +export default SettingsPage; diff --git a/app/(platform)/(dashboard)/organization/layout.tsx b/app/(platform)/(dashboard)/organization/layout.tsx new file mode 100644 index 0000000..39d82cb --- /dev/null +++ b/app/(platform)/(dashboard)/organization/layout.tsx @@ -0,0 +1,16 @@ +import { Sidebar } from "../_components/sidebar"; + +const OrganizationLayout = ({ children }: { children: React.ReactNode }) => { + return ( +
    +
    +
    + +
    + {children} +
    +
    + ); +}; + +export default OrganizationLayout; diff --git a/app/(platform)/layout.tsx b/app/(platform)/layout.tsx new file mode 100644 index 0000000..7405780 --- /dev/null +++ b/app/(platform)/layout.tsx @@ -0,0 +1,19 @@ +import { Toaster } from "sonner"; +import { ClerkProvider } from "@clerk/nextjs"; + +import { ModalProvider } from "@/components/providers/modal-provider"; +import { QueryProvider } from "@/components/providers/query-provider"; + +const PlatformLayout = ({ children }: { children: React.ReactNode }) => { + return ( + + + + + {children} + + + ); +}; + +export default PlatformLayout; diff --git a/app/api/cards/[cardId]/logs/route.ts b/app/api/cards/[cardId]/logs/route.ts new file mode 100644 index 0000000..04147bb --- /dev/null +++ b/app/api/cards/[cardId]/logs/route.ts @@ -0,0 +1,35 @@ +import { auth } from "@clerk/nextjs"; +import { ENTITY_TYPE } from "@prisma/client"; +import { NextResponse } from "next/server"; + +import { db } from "@/lib/db"; + +export async function GET( + req: Request, + { params }: { params: { cardId: string } } +) { + try { + const { orgId, userId } = auth(); + + if (!orgId || !userId) + return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }); + + const auditLogs = await db.auditLog.findMany({ + where: { + orgId, + entityId: params.cardId, + entityType: ENTITY_TYPE.CARD, + }, + orderBy: { + createdAt: "desc", + }, + take: 3, + }); + + return NextResponse.json(auditLogs); + } catch (error) { + return new NextResponse(JSON.stringify(error), { status: 500 }); + } +} diff --git a/app/api/cards/[cardId]/route.ts b/app/api/cards/[cardId]/route.ts new file mode 100644 index 0000000..fe979ea --- /dev/null +++ b/app/api/cards/[cardId]/route.ts @@ -0,0 +1,40 @@ +import { auth } from "@clerk/nextjs"; +import { NextResponse } from "next/server"; + +import { db } from "@/lib/db"; + +export async function GET( + req: Request, + { params }: { params: { cardId: string } } +) { + try { + const { orgId, userId } = auth(); + + if (!orgId || !userId) + return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }); + + const card = await db.card.findUnique({ + where: { + id: params.cardId, + list: { + board: { + orgId, + }, + }, + }, + include: { + list: { + select: { + title: true, + }, + }, + }, + }); + + return NextResponse.json(card); + } catch (error) { + return new NextResponse(JSON.stringify(error), { status: 500 }); + } +} diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..4c2a551 --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,69 @@ +import Stripe from "stripe"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +import { db } from "@/lib/db"; +import { stripe } from "@/lib/stripe"; + +export async function POST(req: Request) { + const body = await req.text(); + const signature = headers().get("Stripe-Signature") as string; + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ); + } catch (error) { + return new NextResponse(JSON.stringify(error), { status: 400 }); + } + + const session = event.data.object as Stripe.Checkout.Session; + + if (event.type === "checkout.session.completed") { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ); + + if (!session?.metadata?.orgId) { + return new NextResponse(JSON.stringify({ error: "Missing orgId" }), { + status: 400, + }); + } + + await db.orgSubscription.create({ + data: { + orgId: session.metadata.orgId, + stripeSubscriptionId: subscription.id, + stripeCustomerId: session.customer as string, + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }); + } + + if (event.type === "invoice.payment_succeeded") { + const subscription = await stripe.subscriptions.retrieve( + session.subscription as string + ); + + await db.orgSubscription.update({ + where: { + stripeSubscriptionId: subscription.id, + }, + data: { + stripePriceId: subscription.items.data[0].price.id, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + }, + }); + } + + return NextResponse.json(null, { status: 200 }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..45d7e6d --- /dev/null +++ b/app/globals.css @@ -0,0 +1,82 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +:root { + height: 100%; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..a77f69c --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { siteConfig } from "@/config/site"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: { + default: siteConfig.name, + template: `%s | ${siteConfig.name}`, + }, + description: siteConfig.description, + icons: [ + { + url: "/favicon.svg", + href: "/favicon.svg", + }, + ], +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..1e879bc --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/activity-item.tsx b/components/activity-item.tsx new file mode 100644 index 0000000..523244f --- /dev/null +++ b/components/activity-item.tsx @@ -0,0 +1,30 @@ +import { format } from "date-fns"; +import { AuditLog } from "@prisma/client"; + +import { generateLogMessage } from "@/lib/generate-log-message"; +import { Avatar, AvatarImage } from "@/components/ui/avatar"; + +interface ActivityItemProps { + data: AuditLog; +} + +export const ActivityItem = ({ data }: ActivityItemProps) => { + return ( +
  • + + + +
    +

    + + {data.userName} + {" "} + {generateLogMessage(data)} +

    +

    + {format(new Date(data.createdAt), "MMM d, yyyy 'at' h:mm a")} +

    +
    +
  • + ); +}; diff --git a/components/form/form-errors.tsx b/components/form/form-errors.tsx new file mode 100644 index 0000000..280243b --- /dev/null +++ b/components/form/form-errors.tsx @@ -0,0 +1,28 @@ +import { XCircle } from "lucide-react"; + +interface FormErrorsProps { + id: string; + errors?: Record; +} + +export const FormErrors = ({ id, errors }: FormErrorsProps) => { + if (!errors) return null; + + return ( +
    + {errors?.[id]?.map((error: string) => ( +
    + + {error} +
    + ))} +
    + ); +}; diff --git a/components/form/form-input.tsx b/components/form/form-input.tsx new file mode 100644 index 0000000..0d5f919 --- /dev/null +++ b/components/form/form-input.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { forwardRef } from "react"; +import { useFormStatus } from "react-dom"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { FormErrors } from "./form-errors"; + +interface FormInputProps { + id: string; + label?: string; + type?: string; + placeholder?: string; + required?: boolean; + disabled?: boolean; + errors?: Record; + className?: string; + defaultValue?: string; + onBlur?: () => void; +} + +export const FormInput = forwardRef( + ( + { + id, + label, + type, + placeholder, + required, + disabled, + errors, + className, + defaultValue = "", + onBlur, + }, + ref + ) => { + const { pending } = useFormStatus(); + + return ( +
    +
    + {label ? ( + + ) : null} + +
    + +
    + ); + } +); + +FormInput.displayName = "FormInput"; diff --git a/components/form/form-picker.tsx b/components/form/form-picker.tsx new file mode 100644 index 0000000..69c7cbd --- /dev/null +++ b/components/form/form-picker.tsx @@ -0,0 +1,109 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; + +import { useEffect, useState } from "react"; +import { useFormStatus } from "react-dom"; +import { Check, Loader2 } from "lucide-react"; + +import { unsplash } from "@/lib/unsplash"; +import { cn } from "@/lib/utils"; +import { defaultImages } from "@/constants/images"; +import { FormErrors } from "./form-errors"; + +interface FormPickerProps { + id: string; + errors?: Record; +} + +export const FormPicker = ({ id, errors }: FormPickerProps) => { + const { pending } = useFormStatus(); + + const [images, setImages] = + useState>>(defaultImages); + const [isLoading, setIsLoading] = useState(true); + const [selectedImageId, setSelectedImageId] = useState(null); + + useEffect(() => { + const fetchImages = async () => { + try { + const result = await unsplash.photos.getRandom({ + collectionIds: ["317099"], + count: 9, + }); + + if (result?.response) { + const newImages = result.response as Array>; + setImages(newImages); + } else { + console.error("Failed to get images."); + } + } catch (error) { + console.log(error); + setImages(defaultImages); + } finally { + setIsLoading(false); + } + }; + + fetchImages(); + }, []); + + if (isLoading) { + return ( +
    + +
    + ); + } + + return ( +
    +
    + {images.map((image) => ( +
    { + if (pending) return; + setSelectedImageId(image.id); + }} + > + + Unsplash image + {selectedImageId === image.id && ( +
    + +
    + )} + + {image.user.name} + +
    + ))} +
    + +
    + ); +}; diff --git a/components/form/form-popover.tsx b/components/form/form-popover.tsx new file mode 100644 index 0000000..3aa4c57 --- /dev/null +++ b/components/form/form-popover.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { X } from "lucide-react"; +import { toast } from "sonner"; +import { ElementRef, useRef } from "react"; +import { useRouter } from "next/navigation"; + +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useAction } from "@/hooks/use-action"; +import { createBoard } from "@/actions/create-board"; +import { Button } from "@/components//ui/button"; +import { useProModal } from "@/hooks/use-pro-modal"; + +import { FormInput } from "./form-input"; +import { FormSubmit } from "./form-submit"; +import { FormPicker } from "./form-picker"; + +interface FormPopoverProps { + children: React.ReactNode; + side?: "left" | "right" | "top" | "bottom"; + align?: "center" | "start" | "end"; + sideOffset?: number; +} + +export const FormPopover = ({ + children, + side = "bottom", + align, + sideOffset = 0, +}: FormPopoverProps) => { + const proModal = useProModal(); + const router = useRouter(); + const closeRef = useRef>(null); + + const { execute, fieldErrors } = useAction(createBoard, { + onSuccess: (data) => { + toast.success("Board created"); + closeRef.current?.click(); + router.push(`/board/${data.id}`); + }, + onError: (error) => { + toast.error(error); + proModal.onOpen(); + }, + }); + + const onSubmit = (formData: FormData) => { + const title = formData.get("title") as string; + const image = formData.get("image") as string; + + execute({ title, image }); + }; + + return ( + + {children} + +
    + Create board +
    + + + +
    +
    + + +
    + Create +
    +
    +
    + ); +}; diff --git a/components/form/form-submit.tsx b/components/form/form-submit.tsx new file mode 100644 index 0000000..91905b1 --- /dev/null +++ b/components/form/form-submit.tsx @@ -0,0 +1,41 @@ +"Use client"; + +import { useFormStatus } from "react-dom"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +interface FormSubmitProps { + children: React.ReactNode; + disabled?: boolean; + className?: string; + variant?: + | "default" + | "destructive" + | "outline" + | "secondary" + | "ghost" + | "link" + | "primary"; +} + +export const FormSubmit = ({ + children, + disabled, + className, + variant = "primary", +}: FormSubmitProps) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; diff --git a/components/form/form-textarea.tsx b/components/form/form-textarea.tsx new file mode 100644 index 0000000..7e5dcec --- /dev/null +++ b/components/form/form-textarea.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useFormStatus } from "react-dom"; +import { KeyboardEventHandler, forwardRef } from "react"; + +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; + +import { FormErrors } from "./form-errors"; + +interface FormTextareaProps { + id: string; + label?: string; + placeholder?: string; + required?: boolean; + disabled?: boolean; + errors?: Record; + className?: string; + onBlur?: () => void; + onClick?: () => void; + onKeyDown?: KeyboardEventHandler | undefined; + defaultValue?: string; +} + +export const FormTextarea = forwardRef( + ( + { + id, + label, + placeholder, + required, + disabled, + errors, + className, + onBlur, + onClick, + onKeyDown, + defaultValue, + }, + ref + ) => { + const { pending } = useFormStatus(); + + return ( +
    +
    + {label ? ( + + ) : null} +