Initial Commit

This commit is contained in:
Ahmad 2024-02-14 21:30:10 -05:00
commit f3e2f01bd7
No known key found for this signature in database
GPG key ID: 8FD8A93530D182BF
150 changed files with 13612 additions and 0 deletions

3
.eslintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

40
.gitignore vendored Normal file
View file

@ -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/

201
LICENCE Normal file
View file

@ -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.

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# Tasko
## TODO: Finish README

View file

@ -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<ReturnType> => {
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);

View file

@ -0,0 +1,6 @@
import { z } from "zod";
export const CopyCard = z.object({
id: z.string(),
boardId: z.string(),
});

View file

@ -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<typeof CopyCard>;
export type ReturnType = ActionState<InputType, Card>;

View file

@ -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<ReturnType> => {
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);

View file

@ -0,0 +1,6 @@
import { z } from "zod";
export const CopyList = z.object({
id: z.string(),
boardId: z.string(),
})

View file

@ -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<typeof CopyList>;
export type ReturnType = ActionState<InputType, List>;

View file

@ -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<ReturnType> => {
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);

View file

@ -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",
})
});

View file

@ -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<typeof CreateBoard>;
export type ReturnType = ActionState<InputType, Board>;

View file

@ -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<ReturnType> => {
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);

View file

@ -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(),
})

View file

@ -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<typeof CreateCard>;
export type ReturnType = ActionState<InputType, Card>;

View file

@ -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<ReturnType> => {
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);

View file

@ -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(),
})

View file

@ -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<typeof CreateList>;
export type ReturnType = ActionState<InputType, List>;

View file

@ -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<ReturnType> => {
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);

View file

@ -0,0 +1,5 @@
import { z } from "zod";
export const DeleteBoard = z.object({
id: z.string(),
})

View file

@ -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<typeof DeleteBoard>;
export type ReturnType = ActionState<InputType, Board>;

View file

@ -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<ReturnType> => {
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);

View file

@ -0,0 +1,6 @@
import { z } from "zod";
export const DeleteCard = z.object({
id: z.string(),
boardId: z.string(),
});

View file

@ -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<typeof DeleteCard>;
export type ReturnType = ActionState<InputType, Card>;

View file

@ -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<ReturnType> => {
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);

View file

@ -0,0 +1,6 @@
import { z } from "zod";
export const DeleteList = z.object({
id: z.string(),
boardId: z.string(),
})

View file

@ -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<typeof DeleteList>;
export type ReturnType = ActionState<InputType, List>;

View file

@ -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<ReturnType> => {
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);

View file

@ -0,0 +1,3 @@
import { z } from "zod";
export const StripeRedirect = z.object({});

View file

@ -0,0 +1,8 @@
import { z } from "zod";
import { ActionState } from "@/lib/create-safe-action";
import { StripeRedirect } from "./schema";
export type InputType = z.infer<typeof StripeRedirect>;
export type ReturnType = ActionState<InputType, string>;

View file

@ -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<ReturnType> => {
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);

View file

@ -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(),
})

View file

@ -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<typeof UpdateBoard>;
export type ReturnType = ActionState<InputType, Board>;

View file

@ -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<ReturnType> => {
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);

View file

@ -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(),
});

View file

@ -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<typeof UpdateCardOrder>;
export type ReturnType = ActionState<InputType, Card[]>;

View file

@ -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<ReturnType> => {
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);

View file

@ -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(),
});

View file

@ -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<typeof UpdateCard>;
export type ReturnType = ActionState<InputType, Card>;

View file

@ -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<ReturnType> => {
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);

View file

@ -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(),
});

View file

@ -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<typeof UpdateListOrder>;
export type ReturnType = ActionState<InputType, List[]>;

View file

@ -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<ReturnType> => {
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);

View file

@ -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(),
})

View file

@ -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<typeof UpdateList>;
export type ReturnType = ActionState<InputType, List>;

View file

@ -0,0 +1,20 @@
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
export const Footer = () => {
return (
<div className="fixed bottom-0 w-full p-4 border-t bg-slate-100">
<div className="md:max-w-screen-2xl mx-auto flex items-center w-full justify-between">
<Logo />
<div className="space-x-4 md:block md:w-auto flex items-center justify-between w-full">
<Button size="sm" variant="ghost">
Privacy Policy
</Button>
<Button size="sm" variant="ghost">
Terms of Service
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,21 @@
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import Link from "next/link";
export const Navbar = () => {
return (
<div className="fixed top-0 w-full h-14 px-4 border-b shadow-sm bg-white flex items-center">
<div className="md:max-w-screen-2xl mx-auto flex items-center w-full justify-between">
<Logo />
<div className="space-x-4 md:block md:w-auto flex items-center justify-between w-full">
<Button size="sm" variant="outline" asChild>
<Link href="sign-in">Login</Link>
</Button>
<Button size="sm" asChild>
<Link href="sign-up">Get Tasko for Free</Link>
</Button>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,14 @@
import { Footer } from "./_components/footer";
import { Navbar } from "./_components/navbar";
const MarketingLayout = ({ children }: { children: React.ReactNode }) => {
return (
<div className="h-full bg-slate-100">
<Navbar />
<main className="pt-40 pb-20 bg-slate-100">{children}</main>
<Footer />
</div>
);
};
export default MarketingLayout;

54
app/(marketing)/page.tsx Normal file
View file

@ -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 (
<div className="flex items-center justify-center flex-col">
<div
className={cn(
"flex items-center justify-center flex-col",
headingFont.className
)}
>
<div className="mb-4 flex items-center border shadow-sm p-4 bg-amber-100 text-amber-700 rounded-full uppercase">
<Medal className="h-6 w-6 mr-2" />
No 1 task management app
</div>
<h1 className="text-3xl md:text-6xl text-center text-neutral-800 mb-6">
Tasko helps teams move
</h1>
<div className="text-3xl md:text-6xl bg-gradient-to-r from-fuchsia-600 to-pink-600 text-white px-4 p-2 rounded-md pb-4 w-fit">
Work forward
</div>
</div>
<div
className={cn(
"text-sm md:text-xl text-neutral-400 mt-4 max-w-xs md:max-w-2xl text-center mx-auto",
textFont.className
)}
>
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.
</div>
<Button className="mt-6" size="lg" asChild>
<Link href="/sign-up">Get Tasko for free</Link>
</Button>
</div>
);
};
export default MarketingPage;

View file

@ -0,0 +1,9 @@
const ClerkLayout = ({ children }: { children: React.ReactNode }) => {
return (
<div className="h-full flex items-center justify-center">
{children}
</div>
)
}
export default ClerkLayout;

View file

@ -0,0 +1,11 @@
import { OrganizationList } from "@clerk/nextjs";
export default function CreateOrganizationPage() {
return (
<OrganizationList
hidePersonal
afterSelectOrganizationUrl="/organization/:id"
afterCreateOrganizationUrl="/organization/:id"
/>
);
}

View file

@ -0,0 +1,5 @@
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return <SignIn />;
}

View file

@ -0,0 +1,5 @@
import { SignUp } from "@clerk/nextjs";
export default function Page() {
return <SignUp />;
}

View file

@ -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 (
<nav className="fixed z-50 top-0 px-4 w-full h-14 border-b shadow-sm bg-white flex items-center">
<MobileSidebar />
<div className="flex items-center gap-x-4">
<div className="hidden md:flex">
<Logo />
</div>
<FormPopover align="start" side="bottom" sideOffset={18}>
<Button
variant="primary"
size="sm"
className="rounded-sm hidden md:block h-auto py-1.5 px-2"
>
Create
</Button>
</FormPopover>
<FormPopover>
<Button
variant="primary"
size="sm"
className="rounded-sm block md:hidden"
>
<Plus className="h-4 w-4" />
</Button>
</FormPopover>
</div>
<div className="ml-auto flex items-center gap-x-2">
<OrganizationSwitcher
hidePersonal
afterCreateOrganizationUrl="/organization/:id"
afterLeaveOrganizationUrl="/org-select"
afterSelectOrganizationUrl="/organization/:id"
appearance={{
elements: {
rootBox: {
display: "flex",
justifyContent: "center",
alignItems: "center",
},
},
}}
/>
<UserButton
afterSignOutUrl="/"
appearance={{
elements: {
avatarBox: {
height: 30,
width: 30,
},
},
}}
/>
</div>
</nav>
);
};

View file

@ -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 (
<>
<Button
onClick={onOpen}
className="block md:hidden mr-2"
variant="ghost"
size="sm"
>
<Menu className="h-4 w-4" />
</Button>
<Sheet open={isOpen} onOpenChange={onClose}>
<SheetContent side="left" className="p-2 pt-10">
<Sidebar storageKey="t-sidebar-mobile-state" />
</SheetContent>
</Sheet>
</>
);
};

View file

@ -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: <Layout className="h-4 w-4 mr-2" />,
href: `/organization/${organization.id}`,
},
{
label: "Activity",
icon: <Activity className="h-4 w-4 mr-2" />,
href: `/organization/${organization.id}/activity`,
},
{
label: "Settings",
icon: <Settings className="h-4 w-4 mr-2" />,
href: `/organization/${organization.id}/settings`,
},
{
label: "Billing",
icon: <CreditCard className="h-4 w-4 mr-2" />,
href: `/organization/${organization.id}/billing`,
},
];
const onClick = (href: string) => {
router.push(href);
};
return (
<AccordionItem value={organization.id} className="border-none">
<AccordionTrigger
onClick={() => 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"
)}
>
<div className="flex items-center gap-x-2">
<div className="w-7 h-7 relative">
<Image
fill
src={organization.imageUrl}
alt={organization.name}
className="rounded-sm object-cover"
/>
</div>
<span className="font-medium text-sm">{organization.name}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-1 text-neutral-700">
{routes.map((route) => (
<Button
key={route.href}
size="sm"
onClick={() => onClick(route.href)}
className={cn(
"w-full font-normal justify-start pl-10 mb-1",
pathname === route.href && "bg-sky-500/10 text-sky-700"
)}
variant="ghost"
>
{route.icon}
{route.label}
</Button>
))}
</AccordionContent>
</AccordionItem>
);
};
NavItem.Skeleton = function SkeletonNavItem() {
return (
<div className="flex items-center gap-x-2">
<div className="w-10 h-10 relative shrink-0">
<Skeleton className="h-full w-full absolute" />
</div>
<Skeleton className="h-4 w-full" />
</div>
);
};

View file

@ -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<Record<string, any>>(
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 (
<>
<div className="flex items-center justify-between mb-2">
<Skeleton className="h-10 w-[50%]" />
<Skeleton className="h-10 w-10" />
</div>
<div className="space-y-2">
<NavItem.Skeleton />
<NavItem.Skeleton />
<NavItem.Skeleton />
</div>
</>
);
}
return (
<>
<div className="font-medium text-xs flex items-center mb-1">
<span className="pl-4">Workspaces</span>
<Button
asChild
type="button"
size="icon"
variant="ghost"
className="ml-auto"
>
<Link href="/select-org">
<Plus className="h-4 w-4" />
</Link>
</Button>
</div>
<Accordion
type="multiple"
defaultValue={defaultAccordionValue}
className="space-y-2"
>
{userMemberships.data.map(({ organization }) => (
<NavItem
key={organization.id}
isActive={activeOrganization?.id === organization.id}
isExpanded={expanded[organization.id]}
organization={organization as Organization}
onExpand={onExpand}
/>
))}
</Accordion>
</>
);
};

View file

@ -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 (
<div className="w-full h-14 z-[40] bg-black/50 fixed top-14 flex items-center px-6 gap-x-4 text-white">
<BoardTitleForm data={data} />
<div className="ml-auto">
<BoardOptions id={data.id} />
</div>
</div>
);
};

View file

@ -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 (
<Popover>
<PopoverTrigger asChild>
<Button className="h-auto w-auto p-2" variant="transparent">
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="px-0 pt-3 pb-3" side="bottom" align="start">
<div className="text-sm font-medium text-center text-neutral-600 pb-4">
Board Actions
</div>
<PopoverClose asChild>
<Button
className="h-auto w-auto p-2 absolute top-2 right-2 text-neutral-600"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</PopoverClose>
<Button
variant="ghost"
onClick={onDelete}
disabled={isLoading}
className="rounded-none w-full h-auto p-2 px-5 justify-start font-normal text-sm text-destructive hover:text-destructive"
>
Delete this board
</Button>
</PopoverContent>
</Popover>
);
};

View file

@ -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<ElementRef<"form">>(null);
const inputRef = useRef<ElementRef<"input">>(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 (
<form
action={onSubmit}
ref={formRef}
className="flex items-center gap-x-2"
>
<FormInput
ref={inputRef}
id="title"
onBlur={onBlur}
defaultValue={title}
className="text-lg font-bold px-[7px] py-1 h-7 bg-transparent focus-visible:outline-none focus-visible:ring-transparent border-none"
/>
</form>
);
}
return (
<Button
onClick={enableEditing}
variant="transparent"
className="font-bold text-lg h-auto w-auto p-1 px-2"
>
{title}
</Button>
);
};

View file

@ -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<HTMLTextAreaElement, CardFormProps>(
({ listId, isEditing, enableEditing, disableEditing }, ref) => {
const params = useParams();
const formRef = useRef<ElementRef<"form">>(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<HTMLTextAreaElement> = (
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 (
<form
ref={formRef}
action={onSubmit}
className="m-1 py-0.5 px-1 space-y-4"
>
<FormTextarea
id="title"
onKeyDown={onTextareaKeyDown}
ref={ref}
placeholder="Enter a title for this card..."
errors={fieldErrors}
/>
<input hidden id="listId" name="listId" value={listId} />
<div className="flex items-center gap-x-1">
<FormSubmit>Add card</FormSubmit>
<Button onClick={disableEditing} size="sm" variant="ghost">
<X className="w-5 h-5" />
</Button>
</div>
</form>
);
}
return (
<div className="pt-2 px-2">
<Button
onClick={enableEditing}
className="h-auto px-2 py-1.5 w-full justify-start text-muted-foreground text-sm"
size="sm"
variant="ghost"
>
<Plus className="h-4 w-4 mr-2" />
Add a card
</Button>
</div>
);
}
);
CardForm.displayName = "CardForm";

View file

@ -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 (
<Draggable draggableId={data.id} index={index}>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
role="button"
onClick={() => 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}
</div>
)}
</Draggable>
);
};

View file

@ -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<T>(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 (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="lists" type="list" direction="horizontal">
{(provided) => (
<ol
{...provided.droppableProps}
ref={provided.innerRef}
className="flex gap-x-3 h-full"
>
{orderedData.map((list, index) => {
return <ListItem key={list.id} index={index} data={list} />;
})}
{provided.placeholder}
<ListForm />
<div className="flex-shrink-0 w-1" />
</ol>
)}
</Droppable>
</DragDropContext>
);
};

View file

@ -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<ElementRef<"form">>(null);
const inputRef = useRef<ElementRef<"input">>(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 (
<ListWrapper>
<form
action={onSubmit}
ref={formRef}
className="w-full p-3 rounded-md bg-white space-y-4 shadow-md"
>
<FormInput
ref={inputRef}
errors={fieldErrors}
id="title"
className="text-sm px-2 py-1 h-7 font-medium border-transparent hover:border-input focus:border-input transition"
placeholder="Enter list title..."
/>
<input hidden value={params.boardId} name="boardId" />
<div className="flex items-center gap-x-1">
<FormSubmit>Add list</FormSubmit>
<Button onClick={disableEditing} size="sm" variant="ghost">
<X className="h-5 w-5" />
</Button>
</div>
</form>
</ListWrapper>
);
}
return (
<ListWrapper>
<button
onClick={enableEditing}
className="w-full rounded-md bg-white/80 hover:bg-white/50 transition p-3 flex items-center font-medium text-sm"
>
<Plus className="h-4 w-4 mr-2" />
Add a list
</button>
</ListWrapper>
);
};

View file

@ -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<ElementRef<"form">>(null);
const inputRef = useRef<ElementRef<"input">>(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 (
<div className="pt-2 px-2 text-sm font-semibold flex justify-between items-start gap-x-2">
{isEditing ? (
<form ref={formRef} action={onSubmit} className="flex-1 px-[2px]">
<input hidden id="id" name="id" value={data.id} />
<input hidden id="boardId" name="boardId" value={data.boardId} />
<FormInput
ref={inputRef}
onBlur={onBlur}
id="title"
placeholder="Enter list title..."
defaultValue={title}
className="text-sm px-[7px] py-1 h-7 font-medium border-transparent hover:border-input focus:border-input transition truncate bg-transparent focus:bg-white"
/>
<button hidden type="submit" />
</form>
) : (
<div
onClick={enableEditing}
className="w-full text-sm px-2.5 py-1 h-7 font-medium border-transparent"
>
{title}
</div>
)}
<ListOptions onAddCard={onAddCard} data={data} />
</div>
);
};

View file

@ -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<ElementRef<"textarea">>(null);
const [isEditing, setIsEditing] = useState(false);
const disableEditing = () => {
setIsEditing(false);
};
const enableEditing = () => {
setIsEditing(true);
setTimeout(() => {
textareaRef.current?.focus();
});
};
return (
<Draggable draggableId={data.id} index={index}>
{(provided) => (
<li
{...provided.draggableProps}
ref={provided.innerRef}
className="shrink-0 h-full w-[272px] select-none"
>
<div
{...provided.dragHandleProps}
className="w-full rounded-md bg-[#f1f2f4] shadow-md pb-2"
>
<ListHeader onAddCard={enableEditing} data={data} />
<Droppable droppableId={data.id} type="card">
{(provided) => (
<ol
ref={provided.innerRef}
{...provided.droppableProps}
className={cn(
"mx-1 px-1 py-0.5 flex flex-col gap-y-2",
data.cards.length > 0 ? "mt-2" : "mt-0"
)}
>
{data.cards.map((card, index) => (
<CardItem index={index} key={card.id} data={card} />
))}
{provided.placeholder}
</ol>
)}
</Droppable>
<CardForm
listId={data.id}
ref={textareaRef}
isEditing={isEditing}
enableEditing={enableEditing}
disableEditing={disableEditing}
/>
</div>
</li>
)}
</Draggable>
);
};

View file

@ -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<ElementRef<"button">>(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 (
<Popover>
<PopoverTrigger asChild>
<Button className="h-auto w-auto p-2" variant="ghost">
<MoreHorizontal className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="px-0 pt-3 pb-3" side="bottom" align="start">
<div className="text-sm font-medium text-center text-neutral-600 pb-4">
List Actions
</div>
<PopoverClose ref={closeRef} asChild>
<Button
className="h-auto w-auto p-2 absolute top-2 right-2 text-neutral-600"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</PopoverClose>
<Button
onClick={onAddCard}
className="rounded-none w-full h-auto p-2 px-5 justify-start font-normal text-sm"
variant="ghost"
>
Add card...
</Button>
<form action={onCopy}>
<input hidden name="id" id="id" value={data.id} />
<input hidden name="boardId" id="boardId" value={data.boardId} />
<FormSubmit
className="rounded-none w-full h-auto p-2 px-5 justify-start font-normal text-sm"
variant="ghost"
>
Copy list...
</FormSubmit>
</form>
<Separator />
<form action={onDelete}>
<input hidden name="id" id="id" value={data.id} />
<input hidden name="boardId" id="boardId" value={data.boardId} />
<FormSubmit
className="rounded-none w-full h-auto p-2 px-5 justify-start font-normal text-sm text-destructive hover:text-destructive"
variant="ghost"
>
Delete this list
</FormSubmit>
</form>
</PopoverContent>
</Popover>
);
};

View file

@ -0,0 +1,7 @@
interface ListWrapperProps {
children: React.ReactNode;
}
export const ListWrapper = ({ children }: ListWrapperProps) => {
return <li className="shrink-0 h-full w-[272px] select-none">{children}</li>;
};

View file

@ -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 (
<div
className="relative h-full bg-no-repeat bg-cover bg-center"
style={{ backgroundImage: `url(${board.imageFullUrl})` }}
>
<BoardNavbar data={board} />
<div className="absolute inset-0 bg-black/10" />
<main className="relative pt-28 h-full">{children}</main>
</div>
);
};
export default BoardIdLayout;

View file

@ -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 (
<div className="p-4 h-full overflow-x-auto">
<ListContainer boardId={params.boardId} data={lists} />
</div>
)
};
export default BoardIdPage;

View file

@ -0,0 +1,12 @@
import { Navbar } from "./_components/Navbar";
const DashbordLayout = ({ children }: { children: React.ReactNode }) => {
return (
<div className="h-full">
<Navbar />
{children}
</div>
);
};
export default DashbordLayout;

View file

@ -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 (
<div className="space-y-4">
<div className="flex items-center font-semibold text-lg text-neutral-700">
<User2 className="h-6 w-6 mr-2" />
Your boards
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{boards.map((board) => (
<Link
key={board.id}
href={`/board/${board.id}`}
className="group relative aspect-video bg-no-repeat bg-center bg-cover bg-sky-700 rounded-sm h-full w-full p-2 overflow-hidden"
style={{ backgroundImage: `url(${board.imageThumbUrl})` }}
>
<div className="absolute inset-0 bg-black/30 group-hover:bg-black/40 transition" />
<p className="relative font-semibold text-white">{board.title}</p>
</Link>
))}
<FormPopover sideOffset={10} side="right">
<div
role="button"
className="aspect-video relative h-full w-full bg-muted rounded-sm flex flex-col gap-y-1 items-center justify-center hover:opacity-75 transition"
>
<p className="text-sm">Create new board</p>
<span className="text-xs">
{isPro
? "Unlimited"
: `${MAX_FREE_BOARDS - availableCount} remaining`}
</span>
<Hint
sideOffset={40}
description={`
Free Workspaces can have up to 5 open boards. For unlimited boards upgrade this workspace.
`}
>
<HelpCircle className="absolute bottom-2 right-2 h-[14px] w-[14px]" />
</Hint>
</div>
</FormPopover>
</div>
</div>
);
};
BoardList.Skeleton = function SkeletonBoardList() {
return (
<div className="grid gird-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<Skeleton className="aspect-video h-full w-full p-2" />
<Skeleton className="aspect-video h-full w-full p-2" />
<Skeleton className="aspect-video h-full w-full p-2" />
<Skeleton className="aspect-video h-full w-full p-2" />
<Skeleton className="aspect-video h-full w-full p-2" />
<Skeleton className="aspect-video h-full w-full p-2" />
<Skeleton className="aspect-video h-full w-full p-2" />
<Skeleton className="aspect-video h-full w-full p-2" />
</div>
);
};

View file

@ -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 <Info.Skeleton />;
}
return (
<div className="flex items-center gap-x-4">
<div className="w-[60px] h-[60px] relative">
<Image
fill
src={organization?.imageUrl!}
alt="Organization Logo"
className="rounded-md object-cover"
/>
</div>
<div className="space-y-1">
<p className="font-semibold text-xl">{organization?.name}</p>
<div className="flex items-center text-xs text-muted-foreground">
<CreditCard className="h-3 w-3 mr-1" />
{isPro ? "Pro" : "Free"}
</div>
</div>
</div>
);
};
Info.Skeleton = function SkeletonInfo() {
return (
<div className="flex items-center gap-x-4">
<div className="w-[60px] h-[60px] relative">
<Skeleton className="w-full h-full absolute" />
</div>
<div className="space-y-2">
<Skeleton className="h-10 w-[200px]" />
<div className="flex items-center">
<Skeleton className="h-4 w-4 mr-2" />
<Skeleton className="h-4 w-[100px]" />
</div>
</div>
</div>
);
};

View file

@ -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;
};

View file

@ -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 (
<ol className="space-y-4 mt-4">
<p className="hidden last:block text-xs text-center text-muted-foreground">
No activity found inside this organization
</p>
{auditLogs.map((log) => (
<ActivityItem key={log.id} data={log} />
))}
</ol>
);
};
ActivityList.Skeleton = function ActivityListSkeleton() {
return (
<ol className="space-y-4 mt-4">
<Skeleton className="w-[80%] h-14" />
<Skeleton className="w-[50%] h-14" />
<Skeleton className="w-[70%] h-14" />
<Skeleton className="w-[80%] h-14" />
<Skeleton className="w-[75%] h-14" />
</ol>
);
};

View file

@ -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 (
<div className="w-full">
<Info isPro={isPro} />
<Separator className="my-2" />
<Suspense fallback={<ActivityList.Skeleton />}>
<ActivityList />
</Suspense>
</div>
);
};
export default ActivityPage;

View file

@ -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 (
<Button disabled={isLoading} onClick={onClick} variant="primary">
{isPro ? "Manage Subscription" : "Upgrade to Pro"}
</Button>
);
};

View file

@ -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 (
<div className="w-full">
<Info isPro={isPro} />
<Separator className="my-2" />
<SubscriptionButton isPro={isPro} />
</div>
);
};
export default BillingPage;

View file

@ -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 (
<>
<OrgControl />
{children}
</>
);
};
export default OrganizationIdLayout;

View file

@ -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 (
<div className="w-full mb-20">
<Info isPro={isPro} />
<Separator className="my-4" />
<div className="px-2 md:px-4">
<Suspense fallback={<BoardList.Skeleton />}>
<BoardList />
</Suspense>
</div>
</div>
);
};
export default OrganizationIdPage;

View file

@ -0,0 +1,25 @@
import { OrganizationProfile } from "@clerk/nextjs";
const SettingsPage = () => {
return (
<div className="w-full">
<OrganizationProfile
appearance={{
elements: {
rootBox: {
boxShadow: "none",
width: "100%",
},
card: {
border: "1px solid #e5e5e5",
boxShadow: "none",
width: "100%",
},
},
}}
/>
</div>
);
};
export default SettingsPage;

View file

@ -0,0 +1,16 @@
import { Sidebar } from "../_components/sidebar";
const OrganizationLayout = ({ children }: { children: React.ReactNode }) => {
return (
<main className="pt-20 md:pt-24 px-4 max-w-6xl 2xl:max-w-screen-xl mx-auto">
<div className="flex gap-x-7">
<div className="w-64 shrink-0 hidden md:block">
<Sidebar />
</div>
{children}
</div>
</main>
);
};
export default OrganizationLayout;

19
app/(platform)/layout.tsx Normal file
View file

@ -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 (
<ClerkProvider>
<QueryProvider>
<Toaster />
<ModalProvider />
{children}
</QueryProvider>
</ClerkProvider>
);
};
export default PlatformLayout;

View file

@ -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 });
}
}

View file

@ -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 });
}
}

69
app/api/webhook/route.ts Normal file
View file

@ -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 });
}

82
app/globals.css Normal file
View file

@ -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;
}
}

32
app/layout.tsx Normal file
View file

@ -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 (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

17
components.json Normal file
View file

@ -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"
}
}

View file

@ -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 (
<li className="flex items-center gap-x-2">
<Avatar className="h-8 w-8">
<AvatarImage src={data.userImage} />
</Avatar>
<div className="flex flex-col space-y-0.5">
<p className="text-sm text-muted-foreground">
<span className="font-semibold lowercase text-neutral-700">
{data.userName}
</span>{" "}
{generateLogMessage(data)}
</p>
<p className="text-xs text-muted-foreground">
{format(new Date(data.createdAt), "MMM d, yyyy 'at' h:mm a")}
</p>
</div>
</li>
);
};

View file

@ -0,0 +1,28 @@
import { XCircle } from "lucide-react";
interface FormErrorsProps {
id: string;
errors?: Record<string, string[] | undefined>;
}
export const FormErrors = ({ id, errors }: FormErrorsProps) => {
if (!errors) return null;
return (
<div
id={`${id}-error`}
aria-live="polite"
className="mt-2 text-xs text-rose-500"
>
{errors?.[id]?.map((error: string) => (
<div
key={error}
className="flex items-center font-medium p-2 border border-rose-500 bg-rose-500/10 rounded-sm"
>
<XCircle className="h-4 w-4 mr-2" />
{error}
</div>
))}
</div>
);
};

View file

@ -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<string, string[] | undefined>;
className?: string;
defaultValue?: string;
onBlur?: () => void;
}
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
(
{
id,
label,
type,
placeholder,
required,
disabled,
errors,
className,
defaultValue = "",
onBlur,
},
ref
) => {
const { pending } = useFormStatus();
return (
<div className="space-y-2">
<div className="space-y-1">
{label ? (
<Label
htmlFor={id}
className="text-xs font-semibold text-neutral-700"
>
{label}
</Label>
) : null}
<Input
onBlur={onBlur}
defaultValue={defaultValue}
ref={ref}
required={required}
name={id}
id={id}
placeholder={placeholder}
type={type}
disabled={pending ?? disabled}
className={cn("text-sm px-2 py-1 h-7", className)}
aria-describedby={`${id}-error`}
/>
</div>
<FormErrors id={id} errors={errors} />
</div>
);
}
);
FormInput.displayName = "FormInput";

View file

@ -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<string, string[] | undefined>;
}
export const FormPicker = ({ id, errors }: FormPickerProps) => {
const { pending } = useFormStatus();
const [images, setImages] =
useState<Array<Record<string, any>>>(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<Record<string, any>>;
setImages(newImages);
} else {
console.error("Failed to get images.");
}
} catch (error) {
console.log(error);
setImages(defaultImages);
} finally {
setIsLoading(false);
}
};
fetchImages();
}, []);
if (isLoading) {
return (
<div className="p-6 flex items-center justify-center">
<Loader2 className="h-6 w-6 text-sky-700 animate-spin" />
</div>
);
}
return (
<div className="relative">
<div className="grid grid-cols-3 gap-2 mb-2">
{images.map((image) => (
<div
key={image.id}
className={cn(
"cursor-pointer relative aspect-video group hover:opacity-75 transition bg-muted",
pending && "opacity-50 hover:opacity-50 cursor-auto"
)}
onClick={() => {
if (pending) return;
setSelectedImageId(image.id);
}}
>
<input
type="radio"
id={id}
name={id}
className="hidden"
checked={selectedImageId === image.id}
disabled={pending}
value={`${image.id}|${image.urls.thumb}|${image.urls.full}|${image.links.html}|${image.user.name}`}
/>
<Image
src={image.urls.thumb}
alt="Unsplash image"
className="object-cover rounded-sm"
fill
/>
{selectedImageId === image.id && (
<div className="absolute inset-y-0 h-full w-full bg-black/30 flex items-center justify-center">
<Check className="h-4 w-4 text-white" />
</div>
)}
<Link
href={image.links.html}
target="_blank"
className="opacity-0 group-hover:opacity-100 absolute bottom-0 w-full text-[10px] truncate text-white hover:underline p-1 bg-black/50"
>
{image.user.name}
</Link>
</div>
))}
</div>
<FormErrors id="image" errors={errors} />
</div>
);
};

View file

@ -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<ElementRef<"button">>(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 (
<Popover>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent
align={align}
className="w-80 pt-3"
side={side}
sideOffset={sideOffset}
>
<div className="text-sm font-medium text-center text-neutral-600 pb-4">
Create board
</div>
<PopoverClose ref={closeRef} asChild>
<Button
className="h-auto w-auto p-2 absolute top-2 right-2 text-neutral-600"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</PopoverClose>
<form action={onSubmit} className="space-y-4">
<div className="space-y-4">
<FormPicker id="image" errors={fieldErrors} />
<FormInput
id="title"
label="Board Title"
type="text"
errors={fieldErrors}
/>
</div>
<FormSubmit className="w-full">Create</FormSubmit>
</form>
</PopoverContent>
</Popover>
);
};

View file

@ -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 (
<Button
disabled={pending || disabled}
type="submit"
variant={variant}
size="sm"
className={cn(className)}
>
{children}
</Button>
);
};

View file

@ -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<string, string[] | undefined>;
className?: string;
onBlur?: () => void;
onClick?: () => void;
onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement> | undefined;
defaultValue?: string;
}
export const FormTextarea = forwardRef<HTMLTextAreaElement, FormTextareaProps>(
(
{
id,
label,
placeholder,
required,
disabled,
errors,
className,
onBlur,
onClick,
onKeyDown,
defaultValue,
},
ref
) => {
const { pending } = useFormStatus();
return (
<div className="space-y-2 w-full">
<div className="space-y-1 w-full">
{label ? (
<Label
htmlFor={id}
className="text-xs font-semibold text-neutral-700"
>
{label}
</Label>
) : null}
<Textarea
onKeyDown={onKeyDown}
onBlur={onBlur}
onClick={onClick}
ref={ref}
required={required}
placeholder={placeholder}
name={id}
id={id}
disabled={pending || disabled}
className={cn(
"resize-none focus-visible:ring-0 focus-visible:ring-offset-0 ring-0 focus:ring-0 outline-none shadow-sm",
className
)}
aria-describedby={`${id}-error`}
defaultValue={defaultValue}
/>
</div>
<FormErrors id={id} errors={errors} />
</div>
);
}
);
FormTextarea.displayName = "FormTextarea";

35
components/hint.tsx Normal file
View file

@ -0,0 +1,35 @@
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface HintProps {
children: React.ReactNode;
description: string;
side?: "left" | "right" | "top" | "bottom";
sideOffset?: number;
}
export const Hint = ({
children,
description,
side = "bottom",
sideOffset = 0,
}: HintProps) => {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>{children}</TooltipTrigger>
<TooltipContent
sideOffset={sideOffset}
side={side}
className="text-xs max-w-[220px] break-words"
>
{description}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};

12
components/logo.tsx Normal file
View file

@ -0,0 +1,12 @@
import Image from "next/image";
import Link from "next/link";
export const Logo = () => {
return (
<Link href="/">
<div className="hover:opacity-75 transition items-center gap-x-2 hidden md:flex">
<Image src="/logo-transparent.svg" alt="logo" height={100} width={100} />
</div>
</Link>
);
};

View file

@ -0,0 +1,103 @@
"use client";
import { Copy, Trash } from "lucide-react";
import { useParams } from "next/navigation";
import { toast } from "sonner";
import { Skeleton } from "@/components/ui/skeleton";
import { deleteCard } from "@/actions/delete-card";
import { Button } from "@/components/ui/button";
import { useAction } from "@/hooks/use-action";
import { copyCard } from "@/actions/copy-card";
import { CardWithList } from "@/types";
import { useCardModal } from "@/hooks/use-card-modal";
interface ActionsProps {
data: CardWithList;
}
export const Actions = ({ data }: ActionsProps) => {
const params = useParams();
const cardModal = useCardModal();
const { execute: executeDeleteCard, isLoading: isLoadingDelete } = useAction(
deleteCard,
{
onSuccess: () => {
toast.success(`Card "${data.title}" deleted`);
cardModal.onClose();
},
onError: (error) => {
toast.error(error);
},
}
);
const { execute: executeCopyCard, isLoading: isLoadingCopy } = useAction(
copyCard,
{
onSuccess: () => {
toast.success(`Card "${data.title}" copied`);
cardModal.onClose();
},
onError: (error) => {
toast.error(error);
},
}
);
const onCopy = () => {
const boardId = params.boardId as string;
executeCopyCard({
id: data.id,
boardId,
});
};
const onDelete = () => {
const boardId = params.boardId as string;
executeDeleteCard({
id: data.id,
boardId,
});
};
return (
<div className="space-y-2 mt-2">
<p className="text-xs font-semibold">Actions</p>
<Button
onClick={onCopy}
disabled={isLoadingCopy}
variant="gray"
className="w-full justify-start"
size="inline"
>
<Copy className="h-4 w-4 mr-2" />
Copy
</Button>
<Button
onClick={onDelete}
disabled={isLoadingDelete}
variant="gray"
className="w-full justify-start text-destructive"
size="inline"
>
<Trash className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
);
};
Actions.Skeleton = function ActionsSkeleton() {
return (
<div className="space-y-2 mt-2">
<Skeleton className="w-20 h-4 bg-neutral-200" />
<Skeleton className="w-full h-8 bg-neutral-200" />
<Skeleton className="w-full h-8 bg-neutral-200" />
</div>
);
};

Some files were not shown because too many files have changed in this diff Show more