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..00bba9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2204eec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Choose the latest Node.js as base image +FROM node:20-alpine AS base + +# Set the working directory in the Docker container +WORKDIR /app + +# Copy package.json and package-lock.json to the working directory +COPY package*.json ./ + +# Install dependencies in the Docker container +RUN npm install + +# Copy the rest of your app's source code from your host to your image filesystem. +COPY . . + +# Build the Next.js app +RUN npm run build + +# Expose the port 3000 for the app +EXPOSE 3000 + +# Start the app +CMD [ "npm", "start" ] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..75fccf8 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Fullstack Trello Clone: Next.js 14, Server Actions, React, Prisma, Stripe, Tailwind, MySQL, Docker + +![image](https://github.com/AntonioErdeljac/next13-trello/assets/23248726/fd260249-82fa-4588-a67a-69bb4eb09067) + +This is a repository for Fullstack Trello Clone: Next.js 14, Server Actions, React, Prisma, Stripe, Tailwind, MySQL, Docker + +Key Features: + +- Auth +- Organizations / Workspaces +- Board creation +- Activity log for entire organization +- Board rename and delete +- List creation +- List rename, delete, drag & drop reorder and copy +- Card creation +- Card description, rename, delete, drag & drop reorder and copy +- Card activity log +- Board limit for every organization +- Landing page +- MySQL DB +- Prisma ORM +- ShadcnUI & TailwindCSS + +### Prerequisites + +**Node version 18.x.x** + +### Cloning the repository + +```shell +git clone https://github.com/LiusDev/fer202-assignment.git +``` + +### Setup .env file + +```js +NEXTJS_LOCAL_PORT=4000 +NEXTJS_DOCKER_PORT=3000 +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in +NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ +NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ + +MYSQLDB_ROOT_PASSWORD=123456 +MYSQLDB_DATABASE=taskify_db +MYSQLDB_LOCAL_PORT=3307 +MYSQLDB_DOCKER_PORT=3306 +``` + +### Run Docker + +```shell +docker-compose up --build +``` + +### Setup Prisma + +Add MySQL Database + +```shell +docker-compose exec taskify npx prisma generate +docker-compose exec taskify npx prisma db push +``` + +App will be running on [http://localhost:4000](http://localhost:4000) diff --git a/actions/copy-card/index.ts b/actions/copy-card/index.ts new file mode 100644 index 0000000..a1026c4 --- /dev/null +++ b/actions/copy-card/index.ts @@ -0,0 +1,75 @@ +"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 { CopyCard } from "./schema"; +import { InputType, ReturnType } from "./types"; + +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, + entityId: card.id, + entityType: ENTITY_TYPE.CARD, + action: ACTION.CREATE, + }) + } catch (error) { + return { + error: "Failed to copy." + } + } + + 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..ca0a5ce --- /dev/null +++ b/actions/copy-list/index.ts @@ -0,0 +1,88 @@ +"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 { CopyList } from "./schema"; +import { InputType, ReturnType } from "./types"; + +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, + entityId: list.id, + entityType: ENTITY_TYPE.LIST, + action: ACTION.CREATE, + }) + } catch (error) { + return { + error: "Failed to copy." + } + } + + 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..4c364e2 --- /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(), +}); diff --git a/actions/copy-list/types.ts b/actions/copy-list/types.ts new file mode 100644 index 0000000..fbde1da --- /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; diff --git a/actions/create-board/index.ts b/actions/create-board/index.ts new file mode 100644 index 0000000..323dc54 --- /dev/null +++ b/actions/create-board/index.ts @@ -0,0 +1,88 @@ +"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." + } + } + + 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..65933bc --- /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 is required", + }).min(3, { + message: "Title is too short." + }), + image: z.string({ + required_error: "Image is required", + invalid_type_error: "Image is required", + }), +}); diff --git a/actions/create-board/types.ts b/actions/create-board/types.ts new file mode 100644 index 0000000..f671ae6 --- /dev/null +++ b/actions/create-board/types.ts @@ -0,0 +1,9 @@ +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..15a0856 --- /dev/null +++ b/actions/create-card/index.ts @@ -0,0 +1,74 @@ +"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 { CreateCard } from "./schema"; +import { InputType, ReturnType } from "./types"; + +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." + } + } + + 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..55d5294 --- /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: "Title is required", + invalid_type_error: "Title is required", + }).min(3, { + message: "Title is too short", + }), + boardId: z.string(), + listId: z.string(), +}); 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..c5d0bc5 --- /dev/null +++ b/actions/create-list/index.ts @@ -0,0 +1,72 @@ +"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 { CreateList } from "./schema"; +import { InputType, ReturnType } from "./types"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +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, + entityId: list.id, + entityType: ENTITY_TYPE.LIST, + action: ACTION.CREATE, + }) + } catch (error) { + return { + error: "Failed to create." + } + } + + 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..34a79c8 --- /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: "Title is required", + invalid_type_error: "Title is required", + }).min(3, { + message: "Title is too short", + }), + boardId: z.string(), +}); diff --git a/actions/create-list/types.ts b/actions/create-list/types.ts new file mode 100644 index 0000000..71edd31 --- /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; diff --git a/actions/delete-board/index.ts b/actions/delete-board/index.ts new file mode 100644 index 0000000..110391b --- /dev/null +++ b/actions/delete-board/index.ts @@ -0,0 +1,59 @@ +"use server"; + +import { auth } from "@clerk/nextjs"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { db } from "@/lib/db"; +import { createSafeAction } from "@/lib/create-safe-action"; + +import { DeleteBoard } from "./schema"; +import { InputType, ReturnType } from "./types"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; +import { decreaseAvailableCount } 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 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, + entityId: board.id, + entityType: ENTITY_TYPE.BOARD, + action: ACTION.DELETE, + }) + } catch (error) { + return { + error: "Failed to delete." + } + } + + 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..23cf339 --- /dev/null +++ b/actions/delete-board/schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod"; + +export const DeleteBoard = z.object({ + id: z.string(), +}); diff --git a/actions/delete-board/types.ts b/actions/delete-board/types.ts new file mode 100644 index 0000000..d5729eb --- /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; diff --git a/actions/delete-card/index.ts b/actions/delete-card/index.ts new file mode 100644 index 0000000..4e0f3ab --- /dev/null +++ b/actions/delete-card/index.ts @@ -0,0 +1,54 @@ +"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 { DeleteCard } from "./schema"; +import { InputType, ReturnType } from "./types"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +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, + entityId: card.id, + entityType: ENTITY_TYPE.CARD, + action: ACTION.DELETE, + }) + } catch (error) { + return { + error: "Failed to delete." + } + } + + 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..b232261 --- /dev/null +++ b/actions/delete-list/index.ts @@ -0,0 +1,53 @@ +"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 { DeleteList } from "./schema"; +import { InputType, ReturnType } from "./types"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +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, + entityId: list.id, + entityType: ENTITY_TYPE.LIST, + action: ACTION.DELETE, + }) + } catch (error) { + return { + error: "Failed to delete." + } + } + + 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..3a34e3f --- /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(), +}); diff --git a/actions/delete-list/types.ts b/actions/delete-list/types.ts new file mode 100644 index 0000000..3a2845c --- /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; diff --git a/actions/stripe-redirect/index.ts b/actions/stripe-redirect/index.ts new file mode 100644 index 0000000..4f17e3b --- /dev/null +++ b/actions/stripe-redirect/index.ts @@ -0,0 +1,86 @@ +"use server"; + +import { auth, currentUser } 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 { StripeRedirect } from "./schema"; +import { InputType, ReturnType } from "./types"; + +import { absoluteUrl } from "@/lib/utils"; +import { stripe } from "@/lib/stripe"; + +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 && 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"], + mode: "subscription", + billing_address_collection: "auto", + customer_email: user.emailAddresses[0].emailAddress, + line_items: [ + { + price_data: { + currency: "USD", + product_data: { + name: "Taskify Pro", + description: "Unlimited boards for your organization" + }, + unit_amount: 2000, + recurring: { + interval: "month" + }, + }, + quantity: 1, + }, + ], + metadata: { + orgId, + }, + }); + + url = stripeSession.url || ""; + } + } catch { + 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..2455a76 --- /dev/null +++ b/actions/update-board/index.ts @@ -0,0 +1,53 @@ +"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 { UpdateBoard } from "./schema"; +import { InputType, ReturnType } from "./types"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +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, + entityId: board.id, + entityType: ENTITY_TYPE.BOARD, + action: ACTION.UPDATE, + }) + } catch (error) { + return { + error: "Failed to update." + } + } + + revalidatePath(`/board/${id}`); + return { data: board }; +}; + +export const updateBoard = createSafeAction(UpdateBoard, handler); diff --git a/actions/update-board/schema.ts b/actions/update-board/schema.ts new file mode 100644 index 0000000..f71a253 --- /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 is required", + }).min(3, { + message: "Title is too short", + }), + id: z.string(), +}); diff --git a/actions/update-board/types.ts b/actions/update-board/types.ts new file mode 100644 index 0000000..3c089cd --- /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; diff --git a/actions/update-card-order/index.ts b/actions/update-card-order/index.ts new file mode 100644 index 0000000..5ae093f --- /dev/null +++ b/actions/update-card-order/index.ts @@ -0,0 +1,53 @@ +"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 { UpdateCardOrder } from "./schema"; +import { InputType, ReturnType } from "./types"; + +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." + } + } + + 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..ede921e --- /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..ca14299 --- /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; diff --git a/actions/update-card/index.ts b/actions/update-card/index.ts new file mode 100644 index 0000000..a3b18ea --- /dev/null +++ b/actions/update-card/index.ts @@ -0,0 +1,57 @@ +"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 { UpdateCard } from "./schema"; +import { InputType, ReturnType } from "./types"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +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, + entityId: card.id, + entityType: ENTITY_TYPE.CARD, + action: ACTION.UPDATE, + }) + } catch (error) { + return { + error: "Failed to update." + } + } + + 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..91e8f4d --- /dev/null +++ b/actions/update-card/schema.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +export const UpdateCard = z.object({ + boardId: z.string(), + description: z.optional( + z.string({ + required_error: "Description is required", + invalid_type_error: "Description is required", + }).min(3, { + message: "Description is too short.", + }), + ), + title: z.optional( + z.string({ + required_error: "Title is required", + invalid_type_error: "Title is required", + }).min(3, { + message: "Title is too short", + }) + ), + id: z.string(), +}); diff --git a/actions/update-card/types.ts b/actions/update-card/types.ts new file mode 100644 index 0000000..ec2b02a --- /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; diff --git a/actions/update-list-order/index.ts b/actions/update-list-order/index.ts new file mode 100644 index 0000000..12e3a97 --- /dev/null +++ b/actions/update-list-order/index.ts @@ -0,0 +1,50 @@ +"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 { UpdateListOrder } from "./schema"; +import { InputType, ReturnType } from "./types"; + +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." + } + } + + 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..21650c1 --- /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..aade85a --- /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; diff --git a/actions/update-list/index.ts b/actions/update-list/index.ts new file mode 100644 index 0000000..aebe894 --- /dev/null +++ b/actions/update-list/index.ts @@ -0,0 +1,56 @@ +"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 { UpdateList } from "./schema"; +import { InputType, ReturnType } from "./types"; +import { createAuditLog } from "@/lib/create-audit-log"; +import { ACTION, ENTITY_TYPE } from "@prisma/client"; + +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, + entityId: list.id, + entityType: ENTITY_TYPE.CARD, + action: ACTION.UPDATE, + }) + } catch (error) { + return { + error: "Failed to update." + } + } + + 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..9eaf5bb --- /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 is required", + }).min(3, { + message: "Title is too short", + }), + id: z.string(), + boardId: z.string(), +}); diff --git a/actions/update-list/types.ts b/actions/update-list/types.ts new file mode 100644 index 0000000..24556d3 --- /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; 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..90eafee --- /dev/null +++ b/app/(marketing)/_components/navbar.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; + +import { Logo } from "@/components/logo"; +import { Button } from "@/components/ui/button"; + +export const Navbar = () => { + return ( +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..12678fe --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,20 @@ +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..805a7f7 --- /dev/null +++ b/app/(marketing)/page.tsx @@ -0,0 +1,61 @@ +import Link from "next/link"; +import localFont from "next/font/local"; +import { Poppins } from "next/font/google"; +import { Medal } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +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 managment +
+

+ Taskify helps team 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 Taskify. +
+ +
+ ); +}; + +export default MarketingPage; diff --git a/app/(platform)/(clerk)/layout.tsx b/app/(platform)/(clerk)/layout.tsx new file mode 100644 index 0000000..632f435 --- /dev/null +++ b/app/(platform)/(clerk)/layout.tsx @@ -0,0 +1,11 @@ +const ClerkLayout = ({ children }: { + children: React.ReactNode; +}) => { + return ( +
+ {children} +
+ ); +}; + +export default ClerkLayout; 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..578368d --- /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/[[...sign-in]]/page.tsx b/app/(platform)/(clerk)/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 0000000..10b73f8 --- /dev/null +++ b/app/(platform)/(clerk)/sign-in/[[...sign-in]]/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..fce1573 --- /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 ; +} \ No newline at end of file diff --git a/app/(platform)/(dashboard)/_components/mobile-sidebar.tsx b/app/(platform)/(dashboard)/_components/mobile-sidebar.tsx new file mode 100644 index 0000000..6e6529c --- /dev/null +++ b/app/(platform)/(dashboard)/_components/mobile-sidebar.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Menu } from "lucide-react"; +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; + +import { useMobileSidebar } from "@/hooks/use-mobile-sidebar"; +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 ( + <> + + + + + + + + ) +} \ No newline at end of file diff --git a/app/(platform)/(dashboard)/_components/nav-item.tsx b/app/(platform)/(dashboard)/_components/nav-item.tsx new file mode 100644 index 0000000..daddce9 --- /dev/null +++ b/app/(platform)/(dashboard)/_components/nav-item.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useRouter, usePathname } from "next/navigation"; +import Image from "next/image"; +import { + Activity, + CreditCard, + Layout, + Settings, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + AccordionContent, + AccordionItem, + AccordionTrigger +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +export type Organization = { + id: string; + slug: string; + imageUrl: string; + name: string; +}; + +interface NavItemProps { + isExpanded: boolean; + isActive: boolean; + organization: Organization; + onExpand: (id: string) => void; +}; + +export const NavItem = ({ + isExpanded, + isActive, + organization, + onExpand, +}: NavItemProps) => { + 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 +
+ + {organization.name} + +
+
+ + {routes.map((route) => ( + + ))} + +
+ ); +}; + +NavItem.Skeleton = function SkeletonNavItem() { + return ( +
+
+ +
+ +
+ ); +}; diff --git a/app/(platform)/(dashboard)/_components/navbar.tsx b/app/(platform)/(dashboard)/_components/navbar.tsx new file mode 100644 index 0000000..932102f --- /dev/null +++ b/app/(platform)/(dashboard)/_components/navbar.tsx @@ -0,0 +1,59 @@ +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/sidebar.tsx b/app/(platform)/(dashboard)/_components/sidebar.tsx new file mode 100644 index 0000000..d433302 --- /dev/null +++ b/app/(platform)/(dashboard)/_components/sidebar.tsx @@ -0,0 +1,109 @@ +"use client"; + +import Link from "next/link"; +import { Plus } from "lucide-react"; +import { useLocalStorage } from "usehooks-ts"; +import { useOrganization, useOrganizationList } from "@clerk/nextjs"; + +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Accordion } from "@/components/ui/accordion"; + +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 (!isLoadedOrg || !isLoadedOrgList || 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..39ace71 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/board-navbar.tsx @@ -0,0 +1,21 @@ +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..b2d0303 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/board-options.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { toast } from "sonner"; +import { MoreHorizontal, X } from "lucide-react"; + +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..0afdbe6 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/board-title-form.tsx @@ -0,0 +1,84 @@ +"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..7ec4437 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/card-form.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { toast } from "sonner"; +import { Plus, X } from "lucide-react"; +import { + forwardRef, + useRef, + ElementRef, + KeyboardEventHandler, +} from "react"; +import { useParams } from "next/navigation"; +import { useOnClickOutside, useEventListener } from "usehooks-ts"; + +import { useAction } from "@/hooks/use-action"; +import { createCard } from "@/actions/create-card"; +import { Button } from "@/components/ui/button"; +import { FormSubmit } from "@/components/form/form-submit"; +import { FormTextarea } from "@/components/form/form-textarea"; + +interface CardFormProps { + listId: string; + enableEditing: () => void; + disableEditing: () => void; + isEditing: boolean; +}; + +export const CardForm = forwardRef(({ + listId, + enableEditing, + disableEditing, + isEditing, +}, 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"; \ No newline at end of file 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..9a97428 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/card-item.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Card } from "@prisma/client"; +import { Draggable } from "@hello-pangea/dnd"; + +import { useCardModal } from "@/hooks/use-card-modal"; + +interface CardItemProps { + data: Card; + index: number; +}; + +export const CardItem = ({ + data, + index, +}: 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..acc2cab --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-container.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { toast } from "sonner"; +import { useEffect, useState } from "react"; +import { DragDropContext, Droppable } from "@hello-pangea/dnd"; + +import { ListWithCards } from "@/types"; +import { useAction } from "@/hooks/use-action"; +import { updateListOrder } from "@/actions/update-list-order"; +import { updateCardOrder } from "@/actions/update-card-order"; + +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; + } + + // if dropped 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]; + + // Source and destination list + const sourceList = newOrderedData.find(list => list.id === source.droppableId); + const destList = newOrderedData.find(list => list.id === destination.droppableId); + + if (!sourceList || !destList) { + return; + } + + // Check if cards exists on the sourceList + if (!sourceList.cards) { + sourceList.cards = []; + } + + // Check if cards exists on the destList + if (!destList.cards) { + destList.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, idx) => { + card.order = idx; + }); + + sourceList.cards = reorderedCards; + + setOrderedData(newOrderedData); + executeUpdateCardOrder({ + boardId: boardId, + items: reorderedCards, + }); + // User moves the card to another list + } else { + // Remove card from the source list + const [movedCard] = sourceList.cards.splice(source.index, 1); + + // Assign the new listId to the moved card + movedCard.listId = destination.droppableId; + + // Add card to the destination list + destList.cards.splice(destination.index, 0, movedCard); + + sourceList.cards.forEach((card, idx) => { + card.order = idx; + }); + + // Update the order for each card in the destination list + destList.cards.forEach((card, idx) => { + card.order = idx; + }); + + setOrderedData(newOrderedData); + executeUpdateCardOrder({ + boardId: boardId, + items: destList.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..22f0f3e --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-form.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { toast } from "sonner"; +import { Plus, X } from "lucide-react"; +import { useParams, useRouter } from "next/navigation"; +import { useState, useRef, ElementRef } from "react"; +import { useEventListener, useOnClickOutside } from "usehooks-ts"; + +import { useAction } from "@/hooks/use-action"; +import { Button } from "@/components/ui/button"; +import { createList } from "@/actions/create-list"; +import { FormInput } from "@/components/form/form-input"; +import { FormSubmit } from "@/components/form/form-submit"; + +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..05552e0 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-header.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { toast } from "sonner"; +import { List } from "@prisma/client"; +import { useEventListener } from "usehooks-ts"; +import { useState, useRef, ElementRef } from "react"; + +import { useAction } from "@/hooks/use-action"; +import { updateList } from "@/actions/update-list"; +import { FormInput } from "@/components/form/form-input"; + +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 handleSubmit = (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 onBlur = () => { + formRef.current?.requestSubmit(); + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + 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..0d160bc --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-item.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { ElementRef, useRef, useState } from "react"; +import { Draggable, Droppable } from "@hello-pangea/dnd"; + +import { cn } from "@/lib/utils"; +import { ListWithCards } from "@/types"; + +import { CardForm } from "./card-form"; +import { CardItem } from "./card-item"; +import { ListHeader } from "./list-header"; + +interface ListItemProps { + data: ListWithCards; + index: number; +}; + +export const ListItem = ({ + data, + index, +}: 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..dd04d34 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-options.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { toast } from "sonner"; +import { List } from "@prisma/client"; +import { ElementRef, useRef } from "react"; +import { MoreHorizontal, X } from "lucide-react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, + PopoverClose +} from "@/components/ui/popover"; +import { useAction } from "@/hooks/use-action"; +import { Button } from "@/components/ui/button"; +import { copyList } from "@/actions/copy-list"; +import { deleteList } from "@/actions/delete-list"; +import { FormSubmit } from "@/components/form/form-submit"; +import { Separator } from "@/components/ui/separator"; + +interface ListOptionsProps { + data: List; + onAddCard: () => void; +}; + +export const ListOptions = ({ + data, + onAddCard, +}: ListOptionsProps) => { + const closeRef = useRef>(null); + + const { execute: executeDelete } = useAction(deleteList, { + onSuccess: (data) => { + toast.success(`List "${data.title}" deleted`); + closeRef.current?.click(); + }, + onError: (error) => { + toast.error(error); + } + }); + + const { execute: executeCopy } = useAction(copyList, { + onSuccess: (data) => { + 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..d5014d3 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/_components/list-wrapper.tsx @@ -0,0 +1,13 @@ +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..cfd537f --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/layout.tsx @@ -0,0 +1,71 @@ +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..be21f84 --- /dev/null +++ b/app/(platform)/(dashboard)/board/[boardId]/page.tsx @@ -0,0 +1,52 @@ +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..f4a778b --- /dev/null +++ b/app/(platform)/(dashboard)/layout.tsx @@ -0,0 +1,16 @@ +import { Navbar } from "./_components/navbar"; + +const DashboardLayout = ({ + children +}: { + children: React.ReactNode; + }) => { + return ( +
    + + {children} +
    + ); + }; + + export default DashboardLayout; 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..c86ee18 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/_components/board-list.tsx @@ -0,0 +1,92 @@ +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..db40487 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/_components/info.tsx @@ -0,0 +1,62 @@ +"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 +
    +
    +

    + {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..811fc26 --- /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..29137be --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/activity/_components/activity-list.tsx @@ -0,0 +1,46 @@ +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..798f669 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/activity/page.tsx @@ -0,0 +1,24 @@ +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..209cd77 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/billing/_components/subscription-button.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { toast } from "sonner"; + +import { useAction } from "@/hooks/use-action"; +import { Button } from "@/components/ui/button"; +import { stripeRedirect } from "@/actions/stripe-redirect"; +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 ( + + ) +}; \ No newline at end of file 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..cb632dd --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/billing/page.tsx @@ -0,0 +1,22 @@ +import { checkSubscription } from "@/lib/subscription" +import { Separator } from "@/components/ui/separator"; + +import { SubscriptionButton } from "./_components/subscription-button"; + +import { Info } from "../_components/info"; + +const BillingPage = async () => { + const isPro = await checkSubscription(); + + return ( +
    + + + +
    + ); +}; + +export default BillingPage; diff --git a/app/(platform)/(dashboard)/organization/[organizationId]/layout.tsx b/app/(platform)/(dashboard)/organization/[organizationId]/layout.tsx new file mode 100644 index 0000000..a748026 --- /dev/null +++ b/app/(platform)/(dashboard)/organization/[organizationId]/layout.tsx @@ -0,0 +1,27 @@ +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..066e150 --- /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 { Info } from "./_components/info"; +import { BoardList } from "./_components/board-list"; +import { checkSubscription } from "@/lib/subscription"; + +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..9fa5637 --- /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..4efe60c --- /dev/null +++ b/app/(platform)/(dashboard)/organization/layout.tsx @@ -0,0 +1,20 @@ +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..5d3ce7a --- /dev/null +++ b/app/(platform)/layout.tsx @@ -0,0 +1,23 @@ +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..a4f6113 --- /dev/null +++ b/app/api/cards/[cardId]/logs/route.ts @@ -0,0 +1,34 @@ +import { auth } from "@clerk/nextjs"; +import { NextResponse } from "next/server"; +import { ENTITY_TYPE } from "@prisma/client"; + +import { db } from "@/lib/db"; + +export async function GET( + request: Request, + { params }: { params: { cardId: string } } +) { + try { + const { userId, orgId } = auth(); + + if (!userId || !orgId) { + return new NextResponse("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("Internal 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..66ba62c --- /dev/null +++ b/app/api/cards/[cardId]/route.ts @@ -0,0 +1,39 @@ +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 { userId, orgId } = auth(); + + if (!userId || !orgId) { + return new NextResponse("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("Internal Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..82294c8 --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,67 @@ +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("Webhook 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("Org ID is required", { status: 400 }); + } + + await db.orgSubscription.create({ + data: { + orgId: session?.metadata?.orgId, + stripeSubscriptionId: subscription.id, + stripeCustomerId: subscription.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 new NextResponse(null, { status: 200 }); +}; diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..5b7b233 --- /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; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..78bc5ae --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,33 @@ +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: "/logo.svg", + href: "/logo.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..af6ba6d --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$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 + }, + "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..deb5114 --- /dev/null +++ b/components/activity-item.tsx @@ -0,0 +1,31 @@ +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..96d667f --- /dev/null +++ b/components/form/form-errors.tsx @@ -0,0 +1,33 @@ +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..25b256b --- /dev/null +++ b/components/form/form-input.tsx @@ -0,0 +1,75 @@ +"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..5dc2daa --- /dev/null +++ b/components/form/form-picker.tsx @@ -0,0 +1,114 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +import { Check, Loader2 } from "lucide-react"; +import { useFormStatus } from "react-dom"; +import { useEffect, useState } from "react"; + +import { cn } from "@/lib/utils"; +import { unsplash } from "@/lib/unsplash"; +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 && result.response) { + const newImages = (result.response as Array>); + setImages(newImages); + } else { + console.error("Failed to get images from Unsplash"); + } + } 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..bde564b --- /dev/null +++ b/components/form/form-popover.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { ElementRef, useRef } from "react"; +import { toast } from "sonner"; +import { X } from "lucide-react"; +import { useRouter } from "next/navigation"; + +import { + Popover, + PopoverContent, + PopoverTrigger, + PopoverClose, +} from "@/components/ui/popover"; +import { useAction } from "@/hooks/use-action"; +import { Button } from "@/components/ui/button"; +import { createBoard } from "@/actions/create-board"; +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?: "start" | "center" | "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..e15333c --- /dev/null +++ b/components/form/form-submit.tsx @@ -0,0 +1,34 @@ +"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..7b3ba2d --- /dev/null +++ b/components/form/form-textarea.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useFormStatus } from "react-dom"; +import { KeyboardEventHandler, forwardRef } from "react"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +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, + onBlur, + onClick, + onKeyDown, + className, + defaultValue +}, ref) => { + const { pending } = useFormStatus(); + + return ( +
    +
    + {label ? ( + + ) : null} +