├── .eslintrc.json
├── .gitignore
├── README.md
├── actions
├── issues.js
├── organizations.js
├── projects.js
└── sprints.js
├── app
├── (auth)
│ ├── layout.js
│ ├── sign-in
│ │ └── [[...sign-in]]
│ │ │ └── page.jsx
│ └── sign-up
│ │ └── [[...sign-up]]
│ │ └── page.jsx
├── (main)
│ ├── layout.jsx
│ ├── onboarding
│ │ └── [[...onboarding]]
│ │ │ └── page.jsx
│ ├── organization
│ │ └── [orgId]
│ │ │ ├── _components
│ │ │ ├── delete-project.jsx
│ │ │ ├── project-list.jsx
│ │ │ └── user-issues.jsx
│ │ │ └── page.jsx
│ └── project
│ │ ├── [projectId]
│ │ ├── layout.jsx
│ │ └── page.jsx
│ │ ├── _components
│ │ ├── board-filters.jsx
│ │ ├── create-issue.jsx
│ │ ├── create-sprint.jsx
│ │ ├── sprint-board.jsx
│ │ └── sprint-manager.jsx
│ │ └── create
│ │ └── page.jsx
├── favicon.ico
├── globals.css
├── layout.js
├── lib
│ └── validators.js
├── not-found.jsx
└── page.js
├── components.json
├── components
├── company-carousel.jsx
├── header.jsx
├── issue-card.jsx
├── issue-details-dialog.jsx
├── org-switcher.jsx
├── theme-provider.jsx
├── ui
│ ├── accordion.jsx
│ ├── avatar.jsx
│ ├── badge.jsx
│ ├── button.jsx
│ ├── card.jsx
│ ├── carousel.jsx
│ ├── dialog.jsx
│ ├── drawer.jsx
│ ├── input.jsx
│ ├── popover.jsx
│ ├── select.jsx
│ ├── sonner.jsx
│ ├── tabs.jsx
│ └── textarea.jsx
├── user-avatar.jsx
├── user-loading.jsx
└── user-menu.jsx
├── data
├── companies.json
├── faqs.json
└── status.json
├── hooks
└── use-fetch.js
├── jsconfig.json
├── lib
├── checkUser.js
├── prisma.js
└── utils.js
├── middleware.js
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── prisma
├── migrations
│ ├── 20241008105335_created_models
│ │ └── migration.sql
│ ├── 20241008110923_update_user_table
│ │ └── migration.sql
│ ├── 20241021055013_add_cascading_deletes
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── public
├── companies
│ ├── amazon.svg
│ ├── atlassian.svg
│ ├── google.webp
│ ├── ibm.svg
│ ├── meta.svg
│ ├── microsoft.webp
│ ├── netflix.png
│ └── uber.svg
├── logo.png
└── logo2.png
└── tailwind.config.js
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals",
3 | "rules": {
4 | "no-unused-vars": ["warn"]
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Full Stack Jira Clone with Next JS, React, Tailwind CSS, Prisma, Neon, Clerk, Shadcn UI Tutorial 🔥🔥
2 | ## https://www.youtube.com/watch?v=R5dBYINNouY
3 |
4 | 
5 |
6 | ### Make sure to create a `.env` file with following variables -
7 |
8 | ```
9 | DATABASE_URL=
10 |
11 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
12 | CLERK_SECRET_KEY=
13 |
14 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
15 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
16 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/onboarding
17 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/onboarding
18 | ```
19 |
--------------------------------------------------------------------------------
/actions/issues.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 | import { auth } from "@clerk/nextjs/server";
5 |
6 | export async function getIssuesForSprint(sprintId) {
7 | const { userId, orgId } = auth();
8 |
9 | if (!userId || !orgId) {
10 | throw new Error("Unauthorized");
11 | }
12 |
13 | const issues = await db.issue.findMany({
14 | where: { sprintId: sprintId },
15 | orderBy: [{ status: "asc" }, { order: "asc" }],
16 | include: {
17 | assignee: true,
18 | reporter: true,
19 | },
20 | });
21 |
22 | return issues;
23 | }
24 |
25 | export async function createIssue(projectId, data) {
26 | const { userId, orgId } = auth();
27 |
28 | if (!userId || !orgId) {
29 | throw new Error("Unauthorized");
30 | }
31 |
32 | let user = await db.user.findUnique({ where: { clerkUserId: userId } });
33 |
34 | const lastIssue = await db.issue.findFirst({
35 | where: { projectId, status: data.status },
36 | orderBy: { order: "desc" },
37 | });
38 |
39 | const newOrder = lastIssue ? lastIssue.order + 1 : 0;
40 |
41 | const issue = await db.issue.create({
42 | data: {
43 | title: data.title,
44 | description: data.description,
45 | status: data.status,
46 | priority: data.priority,
47 | projectId: projectId,
48 | sprintId: data.sprintId,
49 | reporterId: user.id,
50 | assigneeId: data.assigneeId || null, // Add this line
51 | order: newOrder,
52 | },
53 | include: {
54 | assignee: true,
55 | reporter: true,
56 | },
57 | });
58 |
59 | return issue;
60 | }
61 |
62 | export async function updateIssueOrder(updatedIssues) {
63 | const { userId, orgId } = auth();
64 |
65 | if (!userId || !orgId) {
66 | throw new Error("Unauthorized");
67 | }
68 |
69 | // Start a transaction
70 | await db.$transaction(async (prisma) => {
71 | // Update each issue
72 | for (const issue of updatedIssues) {
73 | await prisma.issue.update({
74 | where: { id: issue.id },
75 | data: {
76 | status: issue.status,
77 | order: issue.order,
78 | },
79 | });
80 | }
81 | });
82 |
83 | return { success: true };
84 | }
85 |
86 | export async function deleteIssue(issueId) {
87 | const { userId, orgId } = auth();
88 |
89 | if (!userId || !orgId) {
90 | throw new Error("Unauthorized");
91 | }
92 |
93 | const user = await db.user.findUnique({
94 | where: { clerkUserId: userId },
95 | });
96 |
97 | if (!user) {
98 | throw new Error("User not found");
99 | }
100 |
101 | const issue = await db.issue.findUnique({
102 | where: { id: issueId },
103 | include: { project: true },
104 | });
105 |
106 | if (!issue) {
107 | throw new Error("Issue not found");
108 | }
109 |
110 | if (
111 | issue.reporterId !== user.id &&
112 | !issue.project.adminIds.includes(user.id)
113 | ) {
114 | throw new Error("You don't have permission to delete this issue");
115 | }
116 |
117 | await db.issue.delete({ where: { id: issueId } });
118 |
119 | return { success: true };
120 | }
121 |
122 | export async function updateIssue(issueId, data) {
123 | const { userId, orgId } = auth();
124 |
125 | if (!userId || !orgId) {
126 | throw new Error("Unauthorized");
127 | }
128 |
129 | try {
130 | const issue = await db.issue.findUnique({
131 | where: { id: issueId },
132 | include: { project: true },
133 | });
134 |
135 | if (!issue) {
136 | throw new Error("Issue not found");
137 | }
138 |
139 | if (issue.project.organizationId !== orgId) {
140 | throw new Error("Unauthorized");
141 | }
142 |
143 | const updatedIssue = await db.issue.update({
144 | where: { id: issueId },
145 | data: {
146 | status: data.status,
147 | priority: data.priority,
148 | },
149 | include: {
150 | assignee: true,
151 | reporter: true,
152 | },
153 | });
154 |
155 | return updatedIssue;
156 | } catch (error) {
157 | throw new Error("Error updating issue: " + error.message);
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/actions/organizations.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 | import { auth, clerkClient } from "@clerk/nextjs/server";
5 |
6 | export async function getOrganization(slug) {
7 | const { userId } = auth();
8 | if (!userId) {
9 | throw new Error("Unauthorized");
10 | }
11 |
12 | const user = await db.user.findUnique({
13 | where: { clerkUserId: userId },
14 | });
15 |
16 | if (!user) {
17 | throw new Error("User not found");
18 | }
19 |
20 | // Get the organization details
21 | const organization = await clerkClient().organizations.getOrganization({
22 | slug,
23 | });
24 |
25 | if (!organization) {
26 | return null;
27 | }
28 |
29 | // Check if user belongs to this organization
30 | const { data: membership } =
31 | await clerkClient().organizations.getOrganizationMembershipList({
32 | organizationId: organization.id,
33 | });
34 |
35 | const userMembership = membership.find(
36 | (member) => member.publicUserData.userId === userId
37 | );
38 |
39 | // If user is not a member, return null
40 | if (!userMembership) {
41 | return null;
42 | }
43 |
44 | return organization;
45 | }
46 |
47 | export async function getProjects(orgId) {
48 | const { userId } = auth();
49 | if (!userId) {
50 | throw new Error("Unauthorized");
51 | }
52 |
53 | const user = await db.user.findUnique({
54 | where: { clerkUserId: userId },
55 | });
56 |
57 | if (!user) {
58 | throw new Error("User not found");
59 | }
60 |
61 | const projects = await db.project.findMany({
62 | where: { organizationId: orgId },
63 | orderBy: { createdAt: "desc" },
64 | });
65 |
66 | return projects;
67 | }
68 |
69 | export async function getUserIssues(userId) {
70 | const { orgId } = auth();
71 |
72 | if (!userId || !orgId) {
73 | throw new Error("No user id or organization id found");
74 | }
75 |
76 | const user = await db.user.findUnique({
77 | where: { clerkUserId: userId },
78 | });
79 |
80 | if (!user) {
81 | throw new Error("User not found");
82 | }
83 |
84 | const issues = await db.issue.findMany({
85 | where: {
86 | OR: [{ assigneeId: user.id }, { reporterId: user.id }],
87 | project: {
88 | organizationId: orgId,
89 | },
90 | },
91 | include: {
92 | project: true,
93 | assignee: true,
94 | reporter: true,
95 | },
96 | orderBy: { updatedAt: "desc" },
97 | });
98 |
99 | return issues;
100 | }
101 |
102 | export async function getOrganizationUsers(orgId) {
103 | const { userId } = auth();
104 | if (!userId) {
105 | throw new Error("Unauthorized");
106 | }
107 |
108 | const user = await db.user.findUnique({
109 | where: { clerkUserId: userId },
110 | });
111 |
112 | if (!user) {
113 | throw new Error("User not found");
114 | }
115 |
116 | const organizationMemberships =
117 | await clerkClient().organizations.getOrganizationMembershipList({
118 | organizationId: orgId,
119 | });
120 |
121 | const userIds = organizationMemberships.data.map(
122 | (membership) => membership.publicUserData.userId
123 | );
124 |
125 | const users = await db.user.findMany({
126 | where: {
127 | clerkUserId: {
128 | in: userIds,
129 | },
130 | },
131 | });
132 |
133 | return users;
134 | }
135 |
--------------------------------------------------------------------------------
/actions/projects.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 | import { auth, clerkClient } from "@clerk/nextjs/server";
5 |
6 | export async function createProject(data) {
7 | const { userId, orgId } = auth();
8 |
9 | if (!userId) {
10 | throw new Error("Unauthorized");
11 | }
12 |
13 | if (!orgId) {
14 | throw new Error("No Organization Selected");
15 | }
16 |
17 | // Check if the user is an admin of the organization
18 | const { data: membershipList } =
19 | await clerkClient().organizations.getOrganizationMembershipList({
20 | organizationId: orgId,
21 | });
22 |
23 | const userMembership = membershipList.find(
24 | (membership) => membership.publicUserData.userId === userId
25 | );
26 |
27 | if (!userMembership || userMembership.role !== "org:admin") {
28 | throw new Error("Only organization admins can create projects");
29 | }
30 |
31 | try {
32 | const project = await db.project.create({
33 | data: {
34 | name: data.name,
35 | key: data.key,
36 | description: data.description,
37 | organizationId: orgId,
38 | },
39 | });
40 |
41 | return project;
42 | } catch (error) {
43 | throw new Error("Error creating project: " + error.message);
44 | }
45 | }
46 |
47 | export async function getProject(projectId) {
48 | const { userId, orgId } = auth();
49 |
50 | if (!userId || !orgId) {
51 | throw new Error("Unauthorized");
52 | }
53 |
54 | // Find user to verify existence
55 | const user = await db.user.findUnique({
56 | where: { clerkUserId: userId },
57 | });
58 |
59 | if (!user) {
60 | throw new Error("User not found");
61 | }
62 |
63 | // Get project with sprints and organization
64 | const project = await db.project.findUnique({
65 | where: { id: projectId },
66 | include: {
67 | sprints: {
68 | orderBy: { createdAt: "desc" },
69 | },
70 | },
71 | });
72 |
73 | if (!project) {
74 | throw new Error("Project not found");
75 | }
76 |
77 | // Verify project belongs to the organization
78 | if (project.organizationId !== orgId) {
79 | return null;
80 | }
81 |
82 | return project;
83 | }
84 |
85 | export async function deleteProject(projectId) {
86 | const { userId, orgId, orgRole } = auth();
87 |
88 | if (!userId || !orgId) {
89 | throw new Error("Unauthorized");
90 | }
91 |
92 | if (orgRole !== "org:admin") {
93 | throw new Error("Only organization admins can delete projects");
94 | }
95 |
96 | const project = await db.project.findUnique({
97 | where: { id: projectId },
98 | });
99 |
100 | if (!project || project.organizationId !== orgId) {
101 | throw new Error(
102 | "Project not found or you don't have permission to delete it"
103 | );
104 | }
105 |
106 | await db.project.delete({
107 | where: { id: projectId },
108 | });
109 |
110 | return { success: true };
111 | }
112 |
--------------------------------------------------------------------------------
/actions/sprints.js:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { db } from "@/lib/prisma";
4 | import { auth } from "@clerk/nextjs/server";
5 |
6 | export async function createSprint(projectId, data) {
7 | const { userId, orgId } = auth();
8 |
9 | if (!userId || !orgId) {
10 | throw new Error("Unauthorized");
11 | }
12 |
13 | const project = await db.project.findUnique({
14 | where: { id: projectId },
15 | include: { sprints: { orderBy: { createdAt: "desc" } } },
16 | });
17 |
18 | if (!project || project.organizationId !== orgId) {
19 | throw new Error("Project not found");
20 | }
21 |
22 | const sprint = await db.sprint.create({
23 | data: {
24 | name: data.name,
25 | startDate: data.startDate,
26 | endDate: data.endDate,
27 | status: "PLANNED",
28 | projectId: projectId,
29 | },
30 | });
31 |
32 | return sprint;
33 | }
34 |
35 | export async function updateSprintStatus(sprintId, newStatus) {
36 | const { userId, orgId, orgRole } = auth();
37 |
38 | if (!userId || !orgId) {
39 | throw new Error("Unauthorized");
40 | }
41 |
42 | try {
43 | const sprint = await db.sprint.findUnique({
44 | where: { id: sprintId },
45 | include: { project: true },
46 | });
47 | console.log(sprint, orgRole);
48 |
49 | if (!sprint) {
50 | throw new Error("Sprint not found");
51 | }
52 |
53 | if (sprint.project.organizationId !== orgId) {
54 | throw new Error("Unauthorized");
55 | }
56 |
57 | if (orgRole !== "org:admin") {
58 | throw new Error("Only Admin can make this change");
59 | }
60 |
61 | const now = new Date();
62 | const startDate = new Date(sprint.startDate);
63 | const endDate = new Date(sprint.endDate);
64 |
65 | if (newStatus === "ACTIVE" && (now < startDate || now > endDate)) {
66 | throw new Error("Cannot start sprint outside of its date range");
67 | }
68 |
69 | if (newStatus === "COMPLETED" && sprint.status !== "ACTIVE") {
70 | throw new Error("Can only complete an active sprint");
71 | }
72 |
73 | const updatedSprint = await db.sprint.update({
74 | where: { id: sprintId },
75 | data: { status: newStatus },
76 | });
77 |
78 | return { success: true, sprint: updatedSprint };
79 | } catch (error) {
80 | throw new Error(error.message);
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/(auth)/layout.js:
--------------------------------------------------------------------------------
1 | const AuthLayout = ({ children }) => {
2 | return
{children}
;
3 | };
4 |
5 | export default AuthLayout;
6 |
--------------------------------------------------------------------------------
/app/(auth)/sign-in/[[...sign-in]]/page.jsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/(auth)/sign-up/[[...sign-up]]/page.jsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/(main)/layout.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Layout = ({ children }) => {
4 | return {children}
;
5 | };
6 |
7 | export default Layout;
8 |
--------------------------------------------------------------------------------
/app/(main)/onboarding/[[...onboarding]]/page.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { OrganizationList, useOrganization } from "@clerk/nextjs";
4 | import { useRouter } from "next/navigation";
5 | import { useEffect } from "react";
6 |
7 | export default function Onboarding() {
8 | const { organization } = useOrganization();
9 | const router = useRouter();
10 |
11 | useEffect(() => {
12 | if (organization) {
13 | router.push(`/organization/${organization.slug}`);
14 | }
15 | // eslint-disable-next-line react-hooks/exhaustive-deps
16 | }, [organization]);
17 |
18 | return (
19 |
20 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/app/(main)/organization/[orgId]/_components/delete-project.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { Button } from "@/components/ui/button";
5 | import { Trash2 } from "lucide-react";
6 | import { useOrganization } from "@clerk/nextjs";
7 | import { deleteProject } from "@/actions/projects";
8 | import { useRouter } from "next/navigation";
9 | import useFetch from "@/hooks/use-fetch";
10 |
11 | export default function DeleteProject({ projectId }) {
12 | const { membership } = useOrganization();
13 | const router = useRouter();
14 |
15 | const {
16 | loading: isDeleting,
17 | error,
18 | fn: deleteProjectFn,
19 | data: deleted,
20 | } = useFetch(deleteProject);
21 |
22 | const isAdmin = membership?.role === "org:admin";
23 |
24 | const handleDelete = async () => {
25 | if (window.confirm("Are you sure you want to delete this project?")) {
26 | deleteProjectFn(projectId);
27 | }
28 | };
29 |
30 | useEffect(() => {
31 | if (deleted) {
32 | router.refresh();
33 | }
34 | // eslint-disable-next-line react-hooks/exhaustive-deps
35 | }, [deleted]);
36 |
37 | if (!isAdmin) return null;
38 |
39 | return (
40 | <>
41 |
48 |
49 |
50 | {error && {error.message}
}
51 | >
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/app/(main)/organization/[orgId]/_components/project-list.jsx:
--------------------------------------------------------------------------------
1 | // components/ProjectList.jsx
2 | import Link from "next/link";
3 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
4 | import { getProjects } from "@/actions/organizations";
5 | import DeleteProject from "./delete-project";
6 |
7 | export default async function ProjectList({ orgId }) {
8 | const projects = await getProjects(orgId);
9 |
10 | if (projects.length === 0) {
11 | return (
12 |
13 | No projects found.{" "}
14 |
18 | Create New.
19 |
20 |
21 | );
22 | }
23 |
24 | return (
25 |
26 | {projects.map((project) => (
27 |
28 |
29 |
30 | {project.name}
31 |
32 |
33 |
34 |
35 | {project.description}
36 |
40 | View Project
41 |
42 |
43 |
44 | ))}
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/app/(main)/organization/[orgId]/_components/user-issues.jsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import { getUserIssues } from "@/actions/organizations";
3 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4 | import IssueCard from "@/components/issue-card";
5 |
6 | export default async function UserIssues({ userId }) {
7 | const issues = await getUserIssues(userId);
8 |
9 | if (issues.length === 0) {
10 | return null;
11 | }
12 |
13 | const assignedIssues = issues.filter(
14 | (issue) => issue.assignee.clerkUserId === userId
15 | );
16 | const reportedIssues = issues.filter(
17 | (issue) => issue.reporter.clerkUserId === userId
18 | );
19 |
20 | return (
21 | <>
22 | My Issues
23 |
24 |
25 |
26 | Assigned to You
27 | Reported by You
28 |
29 |
30 | Loading...}>
31 |
32 |
33 |
34 |
35 | Loading...}>
36 |
37 |
38 |
39 |
40 | >
41 | );
42 | }
43 |
44 | function IssueGrid({ issues }) {
45 | return (
46 |
47 | {issues.map((issue) => (
48 |
49 | ))}
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/app/(main)/organization/[orgId]/page.jsx:
--------------------------------------------------------------------------------
1 | import { auth } from "@clerk/nextjs/server";
2 | import { redirect } from "next/navigation";
3 | import { getOrganization } from "@/actions/organizations";
4 | import OrgSwitcher from "@/components/org-switcher";
5 | import ProjectList from "./_components/project-list";
6 | import UserIssues from "./_components/user-issues";
7 |
8 | export default async function OrganizationPage({ params }) {
9 | const { orgId } = params;
10 | const { userId } = auth();
11 |
12 | if (!userId) {
13 | redirect("/sign-in");
14 | }
15 |
16 | const organization = await getOrganization(orgId);
17 |
18 | if (!organization) {
19 | return Organization not found
;
20 | }
21 |
22 | return (
23 |
24 |
25 |
26 | {organization.name}’s Projects
27 |
28 |
29 |
30 |
31 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/app/(main)/project/[projectId]/layout.jsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import { BarLoader } from "react-spinners";
3 |
4 | export default async function ProjectLayout({ children }) {
5 | return (
6 |
7 | }>
8 | {children}
9 |
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/app/(main)/project/[projectId]/page.jsx:
--------------------------------------------------------------------------------
1 | import { getProject } from "@/actions/projects";
2 | import { notFound } from "next/navigation";
3 | import SprintCreationForm from "../_components/create-sprint";
4 | import SprintBoard from "../_components/sprint-board";
5 |
6 | export default async function ProjectPage({ params }) {
7 | const { projectId } = params;
8 | const project = await getProject(projectId);
9 |
10 | if (!project) {
11 | notFound();
12 | }
13 |
14 | return (
15 |
16 |
22 |
23 | {project.sprints.length > 0 ? (
24 |
29 | ) : (
30 |
Create a Sprint from button above
31 | )}
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/app/(main)/project/_components/board-filters.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { Input } from "@/components/ui/input";
5 | import { Button } from "@/components/ui/button";
6 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7 | import { X } from "lucide-react";
8 | import {
9 | Select,
10 | SelectContent,
11 | SelectItem,
12 | SelectTrigger,
13 | SelectValue,
14 | } from "@/components/ui/select";
15 |
16 | const priorities = ["LOW", "MEDIUM", "HIGH", "URGENT"];
17 |
18 | export default function BoardFilters({ issues, onFilterChange }) {
19 | const [searchTerm, setSearchTerm] = useState("");
20 | const [selectedAssignees, setSelectedAssignees] = useState([]);
21 | const [selectedPriority, setSelectedPriority] = useState("");
22 |
23 | const assignees = issues
24 | .map((issue) => issue.assignee)
25 | .filter(
26 | (item, index, self) => index === self.findIndex((t) => t.id === item.id)
27 | );
28 |
29 | useEffect(() => {
30 | const filteredIssues = issues.filter(
31 | (issue) =>
32 | issue.title.toLowerCase().includes(searchTerm.toLowerCase()) &&
33 | (selectedAssignees.length === 0 ||
34 | selectedAssignees.includes(issue.assignee?.id)) &&
35 | (selectedPriority === "" || issue.priority === selectedPriority)
36 | );
37 | onFilterChange(filteredIssues);
38 | }, [searchTerm, selectedAssignees, selectedPriority, issues]);
39 |
40 | const toggleAssignee = (assigneeId) => {
41 | setSelectedAssignees((prev) =>
42 | prev.includes(assigneeId)
43 | ? prev.filter((id) => id !== assigneeId)
44 | : [...prev, assigneeId]
45 | );
46 | };
47 |
48 | const clearFilters = () => {
49 | setSearchTerm("");
50 | setSelectedAssignees([]);
51 | setSelectedPriority("");
52 | };
53 |
54 | const isFiltersApplied =
55 | searchTerm !== "" ||
56 | selectedAssignees.length > 0 ||
57 | selectedPriority !== "";
58 |
59 | return (
60 |
61 |
62 |
setSearchTerm(e.target.value)}
67 | />
68 |
69 |
70 |
71 | {assignees.map((assignee, i) => {
72 | const selected = selectedAssignees.includes(assignee.id);
73 |
74 | return (
75 |
0 ? "-ml-6" : ""}`}
80 | style={{
81 | zIndex: i,
82 | }}
83 | onClick={() => toggleAssignee(assignee.id)}
84 | >
85 |
86 |
87 | {assignee.name[0]}
88 |
89 |
90 | );
91 | })}
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 | {priorities.map((priority) => (
101 |
102 | {priority}
103 |
104 | ))}
105 |
106 |
107 |
108 | {isFiltersApplied && (
109 |
114 | Clear Filters
115 |
116 | )}
117 |
118 |
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/app/(main)/project/_components/create-issue.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { BarLoader } from "react-spinners";
5 | import { useForm, Controller } from "react-hook-form";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import {
8 | Drawer,
9 | DrawerContent,
10 | DrawerHeader,
11 | DrawerTitle,
12 | } from "@/components/ui/drawer";
13 | import { Input } from "@/components/ui/input";
14 | import { Button } from "@/components/ui/button";
15 | import {
16 | Select,
17 | SelectContent,
18 | SelectItem,
19 | SelectTrigger,
20 | SelectValue,
21 | } from "@/components/ui/select";
22 | import MDEditor from "@uiw/react-md-editor";
23 | import useFetch from "@/hooks/use-fetch";
24 | import { createIssue } from "@/actions/issues";
25 | import { getOrganizationUsers } from "@/actions/organizations";
26 | import { issueSchema } from "@/app/lib/validators";
27 |
28 | export default function IssueCreationDrawer({
29 | isOpen,
30 | onClose,
31 | sprintId,
32 | status,
33 | projectId,
34 | onIssueCreated,
35 | orgId,
36 | }) {
37 | const {
38 | loading: createIssueLoading,
39 | fn: createIssueFn,
40 | error,
41 | data: newIssue,
42 | } = useFetch(createIssue);
43 |
44 | const {
45 | loading: usersLoading,
46 | fn: fetchUsers,
47 | data: users,
48 | } = useFetch(getOrganizationUsers);
49 |
50 | const {
51 | control,
52 | register,
53 | handleSubmit,
54 | formState: { errors },
55 | reset,
56 | } = useForm({
57 | resolver: zodResolver(issueSchema),
58 | defaultValues: {
59 | priority: "MEDIUM",
60 | description: "",
61 | assigneeId: "",
62 | },
63 | });
64 |
65 | useEffect(() => {
66 | if (isOpen && orgId) {
67 | fetchUsers(orgId);
68 | }
69 | }, [isOpen, orgId]);
70 |
71 | const onSubmit = async (data) => {
72 | await createIssueFn(projectId, {
73 | ...data,
74 | status,
75 | sprintId,
76 | });
77 | };
78 |
79 | useEffect(() => {
80 | if (newIssue) {
81 | reset();
82 | onClose();
83 | onIssueCreated();
84 | }
85 | // eslint-disable-next-line react-hooks/exhaustive-deps
86 | }, [newIssue, createIssueLoading]);
87 |
88 | return (
89 |
90 |
91 |
92 | Create New Issue
93 |
94 | {usersLoading && }
95 |
197 |
198 |
199 | );
200 | }
201 |
--------------------------------------------------------------------------------
/app/(main)/project/_components/create-sprint.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 |
5 | import { Button } from "@/components/ui/button";
6 | import { Input } from "@/components/ui/input";
7 | import {
8 | Popover,
9 | PopoverContent,
10 | PopoverTrigger,
11 | } from "@/components/ui/popover";
12 | import { Card, CardContent } from "@/components/ui/card";
13 |
14 | import { useForm, Controller } from "react-hook-form";
15 | import { zodResolver } from "@hookform/resolvers/zod";
16 | import { useRouter } from "next/navigation";
17 | import { CalendarIcon } from "lucide-react";
18 | import { DayPicker } from "react-day-picker";
19 | import { format, addDays } from "date-fns";
20 |
21 | import { sprintSchema } from "@/app/lib/validators";
22 | import useFetch from "@/hooks/use-fetch";
23 | import { createSprint } from "@/actions/sprints";
24 |
25 | export default function SprintCreationForm({
26 | projectTitle,
27 | projectKey,
28 | projectId,
29 | sprintKey,
30 | }) {
31 | const [showForm, setShowForm] = useState(false);
32 | const [dateRange, setDateRange] = useState({
33 | from: new Date(),
34 | to: addDays(new Date(), 14),
35 | });
36 | const router = useRouter();
37 |
38 | const { loading: createSprintLoading, fn: createSprintFn } =
39 | useFetch(createSprint);
40 |
41 | const {
42 | register,
43 | control,
44 | handleSubmit,
45 | formState: { errors },
46 | } = useForm({
47 | resolver: zodResolver(sprintSchema),
48 | defaultValues: {
49 | name: `${projectKey}-${sprintKey}`,
50 | startDate: dateRange.from,
51 | endDate: dateRange.to,
52 | },
53 | });
54 |
55 | const onSubmit = async (data) => {
56 | await createSprintFn(projectId, {
57 | ...data,
58 | startDate: dateRange.from,
59 | endDate: dateRange.to,
60 | });
61 | setShowForm(false);
62 | router.refresh(); // Refresh the page to show updated data
63 | };
64 |
65 | return (
66 | <>
67 |
68 |
69 | {projectTitle}
70 |
71 | setShowForm(!showForm)}
74 | variant={!showForm ? "default" : "destructive"}
75 | >
76 | {!showForm ? "Create New Sprint" : "Cancel"}
77 |
78 |
79 | {showForm && (
80 |
81 |
82 |
163 |
164 |
165 | )}
166 | >
167 | );
168 | }
169 |
--------------------------------------------------------------------------------
/app/(main)/project/_components/sprint-board.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { toast } from "sonner";
5 | import { Plus } from "lucide-react";
6 | import { Button } from "@/components/ui/button";
7 | import { BarLoader } from "react-spinners";
8 | import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd";
9 | import useFetch from "@/hooks/use-fetch";
10 |
11 | import statuses from "@/data/status";
12 | import { getIssuesForSprint, updateIssueOrder } from "@/actions/issues";
13 |
14 | import SprintManager from "./sprint-manager";
15 | import IssueCreationDrawer from "./create-issue";
16 | import IssueCard from "@/components/issue-card";
17 | import BoardFilters from "./board-filters";
18 |
19 | function reorder(list, startIndex, endIndex) {
20 | const result = Array.from(list);
21 | const [removed] = result.splice(startIndex, 1);
22 | result.splice(endIndex, 0, removed);
23 |
24 | return result;
25 | }
26 |
27 | export default function SprintBoard({ sprints, projectId, orgId }) {
28 | const [currentSprint, setCurrentSprint] = useState(
29 | sprints.find((spr) => spr.status === "ACTIVE") || sprints[0]
30 | );
31 |
32 | const [isDrawerOpen, setIsDrawerOpen] = useState(false);
33 | const [selectedStatus, setSelectedStatus] = useState(null);
34 |
35 | const {
36 | loading: issuesLoading,
37 | error: issuesError,
38 | fn: fetchIssues,
39 | data: issues,
40 | setData: setIssues,
41 | } = useFetch(getIssuesForSprint);
42 |
43 | const [filteredIssues, setFilteredIssues] = useState(issues);
44 |
45 | const handleFilterChange = (newFilteredIssues) => {
46 | setFilteredIssues(newFilteredIssues);
47 | };
48 |
49 | useEffect(() => {
50 | if (currentSprint.id) {
51 | fetchIssues(currentSprint.id);
52 | }
53 | // eslint-disable-next-line react-hooks/exhaustive-deps
54 | }, [currentSprint.id]);
55 |
56 | const handleAddIssue = (status) => {
57 | setSelectedStatus(status);
58 | setIsDrawerOpen(true);
59 | };
60 |
61 | const handleIssueCreated = () => {
62 | fetchIssues(currentSprint.id);
63 | };
64 |
65 | const {
66 | fn: updateIssueOrderFn,
67 | loading: updateIssuesLoading,
68 | error: updateIssuesError,
69 | } = useFetch(updateIssueOrder);
70 |
71 | const onDragEnd = async (result) => {
72 | if (currentSprint.status === "PLANNED") {
73 | toast.warning("Start the sprint to update board");
74 | return;
75 | }
76 | if (currentSprint.status === "COMPLETED") {
77 | toast.warning("Cannot update board after sprint end");
78 | return;
79 | }
80 | const { destination, source } = result;
81 |
82 | if (!destination) {
83 | return;
84 | }
85 |
86 | if (
87 | destination.droppableId === source.droppableId &&
88 | destination.index === source.index
89 | ) {
90 | return;
91 | }
92 |
93 | const newOrderedData = [...issues];
94 |
95 | // source and destination list
96 | const sourceList = newOrderedData.filter(
97 | (list) => list.status === source.droppableId
98 | );
99 |
100 | const destinationList = newOrderedData.filter(
101 | (list) => list.status === destination.droppableId
102 | );
103 |
104 | if (source.droppableId === destination.droppableId) {
105 | const reorderedCards = reorder(
106 | sourceList,
107 | source.index,
108 | destination.index
109 | );
110 |
111 | reorderedCards.forEach((card, i) => {
112 | card.order = i;
113 | });
114 | } else {
115 | // remove card from the source list
116 | const [movedCard] = sourceList.splice(source.index, 1);
117 |
118 | // assign the new list id to the moved card
119 | movedCard.status = destination.droppableId;
120 |
121 | // add new card to the destination list
122 | destinationList.splice(destination.index, 0, movedCard);
123 |
124 | sourceList.forEach((card, i) => {
125 | card.order = i;
126 | });
127 |
128 | // update the order for each card in destination list
129 | destinationList.forEach((card, i) => {
130 | card.order = i;
131 | });
132 | }
133 |
134 | const sortedIssues = newOrderedData.sort((a, b) => a.order - b.order);
135 | setIssues(newOrderedData, sortedIssues);
136 |
137 | updateIssueOrderFn(sortedIssues);
138 | };
139 |
140 | if (issuesError) return Error loading issues
;
141 |
142 | return (
143 |
144 |
150 |
151 | {issues && !issuesLoading && (
152 |
153 | )}
154 |
155 | {updateIssuesError && (
156 |
{updateIssuesError.message}
157 | )}
158 | {(updateIssuesLoading || issuesLoading) && (
159 |
160 | )}
161 |
162 |
163 |
164 | {statuses.map((column) => (
165 |
166 | {(provided) => (
167 |
172 |
173 | {column.name}
174 |
175 | {filteredIssues
176 | ?.filter((issue) => issue.status === column.key)
177 | .map((issue, index) => (
178 |
184 | {(provided) => (
185 |
190 | fetchIssues(currentSprint.id)}
193 | onUpdate={(updated) =>
194 | setIssues((issues) =>
195 | issues.map((issue) => {
196 | if (issue.id === updated.id) return updated;
197 | return issue;
198 | })
199 | )
200 | }
201 | />
202 |
203 | )}
204 |
205 | ))}
206 | {provided.placeholder}
207 | {column.key === "TODO" &&
208 | currentSprint.status !== "COMPLETED" && (
209 |
handleAddIssue(column.key)}
213 | >
214 |
215 | Create Issue
216 |
217 | )}
218 |
219 | )}
220 |
221 | ))}
222 |
223 |
224 |
225 |
setIsDrawerOpen(false)}
228 | sprintId={currentSprint.id}
229 | status={selectedStatus}
230 | projectId={projectId}
231 | onIssueCreated={handleIssueCreated}
232 | orgId={orgId}
233 | />
234 |
235 | );
236 | }
237 |
--------------------------------------------------------------------------------
/app/(main)/project/_components/sprint-manager.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Select,
7 | SelectContent,
8 | SelectItem,
9 | SelectTrigger,
10 | SelectValue,
11 | } from "@/components/ui/select";
12 | import { Badge } from "@/components/ui/badge";
13 |
14 | import { BarLoader } from "react-spinners";
15 | import { formatDistanceToNow, isAfter, isBefore, format } from "date-fns";
16 |
17 | import useFetch from "@/hooks/use-fetch";
18 | import { useRouter, useSearchParams } from "next/navigation";
19 |
20 | import { updateSprintStatus } from "@/actions/sprints";
21 |
22 | export default function SprintManager({
23 | sprint,
24 | setSprint,
25 | sprints,
26 | projectId,
27 | }) {
28 | const [status, setStatus] = useState(sprint.status);
29 | const router = useRouter();
30 | const searchParams = useSearchParams();
31 |
32 | const {
33 | fn: updateStatus,
34 | loading,
35 | error,
36 | data: updatedStatus,
37 | } = useFetch(updateSprintStatus);
38 |
39 | const startDate = new Date(sprint.startDate);
40 | const endDate = new Date(sprint.endDate);
41 | const now = new Date();
42 |
43 | const canStart =
44 | isBefore(now, endDate) && isAfter(now, startDate) && status === "PLANNED";
45 |
46 | const canEnd = status === "ACTIVE";
47 |
48 | const handleStatusChange = async (newStatus) => {
49 | updateStatus(sprint.id, newStatus);
50 | };
51 |
52 | useEffect(() => {
53 | if (updatedStatus && updatedStatus.success) {
54 | setStatus(updatedStatus.sprint.status);
55 | setSprint({
56 | ...sprint,
57 | status: updatedStatus.sprint.status,
58 | });
59 | }
60 | }, [updatedStatus, loading]);
61 |
62 | const getStatusText = () => {
63 | if (status === "COMPLETED") {
64 | return `Sprint Ended`;
65 | }
66 | if (status === "ACTIVE" && isAfter(now, endDate)) {
67 | return `Overdue by ${formatDistanceToNow(endDate)}`;
68 | }
69 | if (status === "PLANNED" && isBefore(now, startDate)) {
70 | return `Starts in ${formatDistanceToNow(startDate)}`;
71 | }
72 | return null;
73 | };
74 |
75 | useEffect(() => {
76 | const sprintId = searchParams.get("sprint");
77 | if (sprintId && sprintId !== sprint.id) {
78 | const selectedSprint = sprints.find((s) => s.id === sprintId);
79 | if (selectedSprint) {
80 | setSprint(selectedSprint);
81 | setStatus(selectedSprint.status);
82 | }
83 | }
84 | }, [searchParams, sprints]);
85 |
86 | const handleSprintChange = (value) => {
87 | const selectedSprint = sprints.find((s) => s.id === value);
88 | setSprint(selectedSprint);
89 | setStatus(selectedSprint.status);
90 | router.replace(`/project/${projectId}`, undefined, { shallow: true });
91 | };
92 |
93 | return (
94 | <>
95 |
96 |
97 |
98 |
99 |
100 |
101 | {sprints.map((sprint) => (
102 |
103 | {sprint.name} ({format(sprint.startDate, "MMM d, yyyy")} to{" "}
104 | {format(sprint.endDate, "MMM d, yyyy")})
105 |
106 | ))}
107 |
108 |
109 |
110 | {canStart && (
111 | handleStatusChange("ACTIVE")}
113 | disabled={loading}
114 | className="bg-green-900 text-white"
115 | >
116 | Start Sprint
117 |
118 | )}
119 | {canEnd && (
120 | handleStatusChange("COMPLETED")}
122 | disabled={loading}
123 | variant="destructive"
124 | >
125 | End Sprint
126 |
127 | )}
128 |
129 | {loading && }
130 | {getStatusText() && (
131 |
132 | {getStatusText()}
133 |
134 | )}
135 | >
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/app/(main)/project/create/page.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import { useForm } from "react-hook-form";
5 | import { zodResolver } from "@hookform/resolvers/zod";
6 | import { useRouter } from "next/navigation";
7 | import { useOrganization, useUser } from "@clerk/nextjs";
8 | import { Button } from "@/components/ui/button";
9 | import { Input } from "@/components/ui/input";
10 | import { Textarea } from "@/components/ui/textarea";
11 | import useFetch from "@/hooks/use-fetch";
12 | import { projectSchema } from "@/app/lib/validators";
13 | import { createProject } from "@/actions/projects";
14 | import { BarLoader } from "react-spinners";
15 | import OrgSwitcher from "@/components/org-switcher";
16 |
17 | export default function CreateProjectPage() {
18 | const router = useRouter();
19 | const { isLoaded: isOrgLoaded, membership } = useOrganization();
20 | const { isLoaded: isUserLoaded } = useUser();
21 | const [isAdmin, setIsAdmin] = useState(false);
22 |
23 | const {
24 | register,
25 | handleSubmit,
26 | formState: { errors },
27 | } = useForm({
28 | resolver: zodResolver(projectSchema),
29 | });
30 |
31 | useEffect(() => {
32 | if (isOrgLoaded && isUserLoaded && membership) {
33 | setIsAdmin(membership.role === "org:admin");
34 | }
35 | }, [isOrgLoaded, isUserLoaded, membership]);
36 |
37 | const {
38 | loading,
39 | error,
40 | data: project,
41 | fn: createProjectFn,
42 | } = useFetch(createProject);
43 |
44 | const onSubmit = async (data) => {
45 | if (!isAdmin) {
46 | alert("Only organization admins can create projects");
47 | return;
48 | }
49 |
50 | createProjectFn(data);
51 | };
52 |
53 | useEffect(() => {
54 | if (project) router.push(`/project/${project.id}`);
55 | }, [loading]);
56 |
57 | if (!isOrgLoaded || !isUserLoaded) {
58 | return null;
59 | }
60 |
61 | if (!isAdmin) {
62 | return (
63 |
64 |
65 | Oops! Only Admins can create projects.
66 |
67 |
68 |
69 | );
70 | }
71 |
72 | return (
73 |
74 |
75 | Create New Project
76 |
77 |
78 |
130 |
131 | );
132 | }
133 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/jira-clone/38beaee1a1f72ac8d5f30a316366f5b493f5c4a0/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | body {
6 | font-family: Arial, Helvetica, sans-serif;
7 | }
8 |
9 | @layer utilities {
10 | .text-balance {
11 | text-wrap: balance;
12 | }
13 | }
14 |
15 | @layer base {
16 | :root {
17 | --background: 0 0% 100%;
18 | --foreground: 0 0% 3.9%;
19 | --card: 0 0% 100%;
20 | --card-foreground: 0 0% 3.9%;
21 | --popover: 0 0% 100%;
22 | --popover-foreground: 0 0% 3.9%;
23 | --primary: 0 0% 9%;
24 | --primary-foreground: 0 0% 98%;
25 | --secondary: 0 0% 96.1%;
26 | --secondary-foreground: 0 0% 9%;
27 | --muted: 0 0% 96.1%;
28 | --muted-foreground: 0 0% 45.1%;
29 | --accent: 0 0% 96.1%;
30 | --accent-foreground: 0 0% 9%;
31 | --destructive: 0 84.2% 60.2%;
32 | --destructive-foreground: 0 0% 98%;
33 | --border: 0 0% 89.8%;
34 | --input: 0 0% 89.8%;
35 | --ring: 0 0% 3.9%;
36 | --chart-1: 12 76% 61%;
37 | --chart-2: 173 58% 39%;
38 | --chart-3: 197 37% 24%;
39 | --chart-4: 43 74% 66%;
40 | --chart-5: 27 87% 67%;
41 | --radius: 0.5rem;
42 | }
43 | .dark {
44 | --background: 0 0% 3.9%;
45 | --foreground: 0 0% 98%;
46 | --card: 0 0% 3.9%;
47 | --card-foreground: 0 0% 98%;
48 | --popover: 0 0% 3.9%;
49 | --popover-foreground: 0 0% 98%;
50 | --primary: 0 0% 98%;
51 | --primary-foreground: 0 0% 9%;
52 | --secondary: 0 0% 14.9%;
53 | --secondary-foreground: 0 0% 98%;
54 | --muted: 0 0% 14.9%;
55 | --muted-foreground: 0 0% 63.9%;
56 | --accent: 0 0% 14.9%;
57 | --accent-foreground: 0 0% 98%;
58 | --destructive: 0 62.8% 30.6%;
59 | --destructive-foreground: 0 0% 98%;
60 | --border: 0 0% 14.9%;
61 | --input: 0 0% 14.9%;
62 | --ring: 0 0% 83.1%;
63 | --chart-1: 220 70% 50%;
64 | --chart-2: 160 60% 45%;
65 | --chart-3: 30 80% 55%;
66 | --chart-4: 280 65% 60%;
67 | --chart-5: 340 75% 55%;
68 | }
69 | }
70 |
71 | @layer base {
72 | * {
73 | @apply border-border;
74 | }
75 | body {
76 | @apply bg-background text-foreground;
77 | }
78 | }
79 |
80 | /* @keyframes moveDots {
81 | 0% {
82 | background-position: 0 0;
83 | }
84 | 100% {
85 | background-position: 80px 80px;
86 | }
87 | } */
88 |
89 | .animated-dotted-background {
90 | background-color: #0a111f;
91 | background-image: radial-gradient(#4a5568 1px, transparent 1px);
92 | background-size: 30px 30px;
93 | /* animation: moveDots 10s linear infinite; */
94 | }
95 |
96 | @layer utilities {
97 | .gradient-title {
98 | @apply font-extrabold bg-gradient-to-br from-blue-500 via-blue-100 to-blue-400 bg-clip-text tracking-tighter text-transparent pr-2 pb-2;
99 | }
100 | }
101 |
102 | .wmde-markdown ul {
103 | list-style-type: disc;
104 | padding-left: 15px !important;
105 | color: white;
106 | }
107 |
--------------------------------------------------------------------------------
/app/layout.js:
--------------------------------------------------------------------------------
1 | import { Inter } from "next/font/google";
2 | import "./globals.css";
3 | import { ThemeProvider } from "@/components/theme-provider";
4 | import Header from "@/components/header";
5 | import { ClerkProvider } from "@clerk/nextjs";
6 | import { shadesOfPurple } from "@clerk/themes";
7 | import "react-day-picker/dist/style.css";
8 | import { Toaster } from "sonner";
9 |
10 | const inter = Inter({ subsets: ["latin"] });
11 |
12 | export const metadata = {
13 | title: "Jira Clone",
14 | description: "",
15 | };
16 |
17 | export default function RootLayout({ children }) {
18 | return (
19 |
36 |
37 |
38 |
39 |
40 | {children}
41 |
42 |
47 |
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/app/lib/validators.js:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const projectSchema = z.object({
4 | name: z
5 | .string()
6 | .min(1, "Project name is required")
7 | .max(100, "Project name must be 100 characters or less"),
8 | key: z
9 | .string()
10 | .min(2, "Project key must be at least 2 characters")
11 | .max(10, "Project key must be 10 characters or less")
12 | .toUpperCase(),
13 | description: z
14 | .string()
15 | .max(500, "Description must be 500 characters or less")
16 | .optional(),
17 | });
18 |
19 | export const sprintSchema = z.object({
20 | name: z.string().min(1, "Sprint name is required"),
21 | startDate: z.date(),
22 | endDate: z.date(),
23 | });
24 |
25 | export const issueSchema = z.object({
26 | title: z.string().min(1, "Title is required"),
27 | assigneeId: z.string().cuid("Please select assignee"),
28 | description: z.string().optional(),
29 | priority: z.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]),
30 | });
31 |
--------------------------------------------------------------------------------
/app/not-found.jsx:
--------------------------------------------------------------------------------
1 | export default function NotFound() {
2 | return (
3 |
4 |
404 - Page Not Found
5 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/app/page.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Link from "next/link";
3 | import {
4 | ChevronRight,
5 | Layout,
6 | Calendar,
7 | BarChart,
8 | ArrowRight,
9 | } from "lucide-react";
10 | import { Button } from "@/components/ui/button";
11 | import { Card, CardContent } from "@/components/ui/card";
12 | import {
13 | Accordion,
14 | AccordionContent,
15 | AccordionItem,
16 | AccordionTrigger,
17 | } from "@/components/ui/accordion";
18 | import CompanyCarousel from "@/components/company-carousel";
19 | import Image from "next/image";
20 |
21 | const faqs = [
22 | {
23 | question: "What is ZCRUM?",
24 | answer:
25 | "ZCRUM is a powerful project management tool designed to help teams organize, track, and manage their work efficiently. It combines intuitive design with robust features to streamline your workflow and boost productivity.",
26 | },
27 | {
28 | question: "How does ZCRUM compare to other project management tools?",
29 | answer:
30 | "ZCRUM offers a unique combination of intuitive design, powerful features, and flexibility. Unlike other tools, we focus on providing a seamless experience for both agile and traditional project management methodologies, making it versatile for various team structures and project types.",
31 | },
32 | {
33 | question: "Is ZCRUM suitable for small teams?",
34 | answer:
35 | "Absolutely! ZCRUM is designed to be scalable and flexible. It works great for small teams and can easily grow with your organization as it expands. Our user-friendly interface ensures that teams of any size can quickly adapt and start benefiting from ZCRUM's features.",
36 | },
37 | {
38 | question: "What key features does ZCRUM offer?",
39 | answer:
40 | "ZCRUM provides a range of powerful features including intuitive Kanban boards for visualizing workflow, robust sprint planning tools for agile teams, comprehensive reporting for data-driven decisions, customizable workflows, time tracking, and team collaboration tools. These features work seamlessly together to enhance your project management experience.",
41 | },
42 | {
43 | question: "Can ZCRUM handle multiple projects simultaneously?",
44 | answer:
45 | "Yes, ZCRUM is built to manage multiple projects concurrently. You can easily switch between projects, and get a bird's-eye view of all your ongoing work. This makes ZCRUM ideal for organizations juggling multiple projects or clients.",
46 | },
47 | {
48 | question: "Is there a learning curve for new users?",
49 | answer:
50 | "While ZCRUM is packed with features, we've designed it with user-friendliness in mind. New users can quickly get up to speed thanks to our intuitive interface, helpful onboarding process, and comprehensive documentation.",
51 | },
52 | ];
53 |
54 | const features = [
55 | {
56 | title: "Intuitive Kanban Boards",
57 | description:
58 | "Visualize your workflow and optimize team productivity with our easy-to-use Kanban boards.",
59 | icon: Layout,
60 | },
61 | {
62 | title: "Powerful Sprint Planning",
63 | description:
64 | "Plan and manage sprints effectively, ensuring your team stays focused on delivering value.",
65 | icon: Calendar,
66 | },
67 | {
68 | title: "Comprehensive Reporting",
69 | description:
70 | "Gain insights into your team's performance with detailed, customizable reports and analytics.",
71 | icon: BarChart,
72 | },
73 | ];
74 |
75 | export default function Home() {
76 | return (
77 |
78 | {/* Hero Section */}
79 |
80 |
81 | Streamline Your Workflow
82 |
83 | with
84 |
91 |
92 |
93 |
94 | Empower your team with our intuitive project management solution.
95 |
96 |
97 |
98 |
99 | Get Started
100 |
101 |
102 |
103 |
104 | Learn More
105 |
106 |
107 |
108 |
109 | {/* Features Section */}
110 |
111 |
112 |
Key Features
113 |
114 | {features.map((feature, index) => (
115 |
116 |
117 |
118 |
119 | {feature.title}
120 |
121 | {feature.description}
122 |
123 |
124 | ))}
125 |
126 |
127 |
128 |
129 | {/* Companies Carousel */}
130 |
131 |
132 |
133 | Trusted by Industry Leaders
134 |
135 |
136 |
137 |
138 |
139 | {/* FAQ Section */}
140 |
141 |
142 |
143 | Frequently Asked Questions
144 |
145 |
146 | {faqs.map((faq, index) => (
147 |
148 | {faq.question}
149 | {faq.answer}
150 |
151 | ))}
152 |
153 |
154 |
155 |
156 | {/* CTA Section */}
157 |
158 |
159 |
160 | Ready to Transform Your Workflow?
161 |
162 |
163 | Join thousands of teams already using ZCRUM to streamline their
164 | projects and boost productivity.
165 |
166 |
167 |
168 | Start For Free
169 |
170 |
171 |
172 |
173 |
174 | );
175 | }
176 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": false,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | }
20 | }
--------------------------------------------------------------------------------
/components/company-carousel.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import Image from "next/image";
5 | import { Carousel, CarouselContent, CarouselItem } from "./ui/carousel";
6 | import Autoplay from "embla-carousel-autoplay";
7 | import companies from "@/data/companies";
8 |
9 | const CompanyCarousel = () => {
10 | return (
11 |
19 |
20 | {companies.map(({ name, id, path }) => (
21 |
22 |
29 |
30 | ))}
31 |
32 |
33 | );
34 | };
35 |
36 | export default CompanyCarousel;
37 |
--------------------------------------------------------------------------------
/components/header.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button } from "./ui/button";
3 | import Link from "next/link";
4 | import { SignedIn, SignedOut, SignInButton } from "@clerk/nextjs";
5 | import UserMenu from "./user-menu";
6 | import { PenBox } from "lucide-react";
7 | import Image from "next/image";
8 | import { checkUser } from "@/lib/checkUser";
9 | import UserLoading from "./user-loading";
10 |
11 | async function Header() {
12 | await checkUser();
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Create Project
33 |
34 |
35 |
36 |
37 | Login
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
51 | export default Header;
52 |
--------------------------------------------------------------------------------
/components/issue-card.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Badge } from "@/components/ui/badge";
5 | import {
6 | Card,
7 | CardContent,
8 | CardFooter,
9 | CardHeader,
10 | CardTitle,
11 | } from "@/components/ui/card";
12 | import { formatDistanceToNow } from "date-fns";
13 | import IssueDetailsDialog from "./issue-details-dialog";
14 | import UserAvatar from "./user-avatar";
15 | import { useRouter } from "next/navigation";
16 |
17 | const priorityColor = {
18 | LOW: "border-green-600",
19 | MEDIUM: "border-yellow-300",
20 | HIGH: "border-orange-400",
21 | URGENT: "border-red-400",
22 | };
23 |
24 | export default function IssueCard({
25 | issue,
26 | showStatus = false,
27 | onDelete = () => {},
28 | onUpdate = () => {},
29 | }) {
30 | const [isDialogOpen, setIsDialogOpen] = useState(false);
31 | const router = useRouter();
32 |
33 | const onDeleteHandler = (...params) => {
34 | router.refresh();
35 | onDelete(...params);
36 | };
37 |
38 | const onUpdateHandler = (...params) => {
39 | router.refresh();
40 | onUpdate(...params);
41 | };
42 |
43 | const created = formatDistanceToNow(new Date(issue.createdAt), {
44 | addSuffix: true,
45 | });
46 |
47 | return (
48 | <>
49 | setIsDialogOpen(true)}
52 | >
53 |
56 | {issue.title}
57 |
58 |
59 |
60 | {showStatus && {issue.status} }
61 |
62 | {issue.priority}
63 |
64 |
65 |
66 |
67 |
68 | Created {created}
69 |
70 |
71 |
72 | {isDialogOpen && (
73 | setIsDialogOpen(false)}
76 | issue={issue}
77 | onDelete={onDeleteHandler}
78 | onUpdate={onUpdateHandler}
79 | borderCol={priorityColor[issue.priority]}
80 | />
81 | )}
82 | >
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/components/issue-details-dialog.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState, useEffect } from "react";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@/components/ui/dialog";
10 | import { Button } from "@/components/ui/button";
11 | import MDEditor from "@uiw/react-md-editor";
12 | import UserAvatar from "./user-avatar";
13 | import useFetch from "@/hooks/use-fetch";
14 | import { useOrganization, useUser } from "@clerk/nextjs";
15 | import {
16 | Select,
17 | SelectContent,
18 | SelectItem,
19 | SelectTrigger,
20 | SelectValue,
21 | } from "@/components/ui/select";
22 | import { BarLoader } from "react-spinners";
23 | import { ExternalLink } from "lucide-react";
24 | import { usePathname, useRouter } from "next/navigation";
25 |
26 | import statuses from "@/data/status";
27 | import { deleteIssue, updateIssue } from "@/actions/issues";
28 |
29 | const priorityOptions = ["LOW", "MEDIUM", "HIGH", "URGENT"];
30 |
31 | export default function IssueDetailsDialog({
32 | isOpen,
33 | onClose,
34 | issue,
35 | onDelete = () => {},
36 | onUpdate = () => {},
37 | borderCol = "",
38 | }) {
39 | const [status, setStatus] = useState(issue.status);
40 | const [priority, setPriority] = useState(issue.priority);
41 | const { user } = useUser();
42 | const { membership } = useOrganization();
43 | const router = useRouter();
44 | const pathname = usePathname();
45 |
46 | const {
47 | loading: deleteLoading,
48 | error: deleteError,
49 | fn: deleteIssueFn,
50 | data: deleted,
51 | } = useFetch(deleteIssue);
52 |
53 | const {
54 | loading: updateLoading,
55 | error: updateError,
56 | fn: updateIssueFn,
57 | data: updated,
58 | } = useFetch(updateIssue);
59 |
60 | const handleDelete = async () => {
61 | if (window.confirm("Are you sure you want to delete this issue?")) {
62 | deleteIssueFn(issue.id);
63 | }
64 | };
65 |
66 | const handleStatusChange = async (newStatus) => {
67 | setStatus(newStatus);
68 | updateIssueFn(issue.id, { status: newStatus, priority });
69 | };
70 |
71 | const handlePriorityChange = async (newPriority) => {
72 | setPriority(newPriority);
73 | updateIssueFn(issue.id, { status, priority: newPriority });
74 | };
75 |
76 | useEffect(() => {
77 | if (deleted) {
78 | onClose();
79 | onDelete();
80 | }
81 | if (updated) {
82 | onUpdate(updated);
83 | }
84 | }, [deleted, updated, deleteLoading, updateLoading]);
85 |
86 | const canChange =
87 | user.id === issue.reporter.clerkUserId || membership.role === "org:admin";
88 |
89 | const handleGoToProject = () => {
90 | router.push(`/project/${issue.projectId}?sprint=${issue.sprintId}`);
91 | };
92 |
93 | const isProjectPage = !pathname.startsWith("/project/");
94 |
95 | return (
96 |
97 |
98 |
99 |
100 | {issue.title}
101 | {isProjectPage && (
102 |
108 |
109 |
110 | )}
111 |
112 |
113 | {(updateLoading || deleteLoading) && (
114 |
115 | )}
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | {statuses.map((option) => (
124 |
125 | {option.name}
126 |
127 | ))}
128 |
129 |
130 |
135 |
136 |
137 |
138 |
139 | {priorityOptions.map((option) => (
140 |
141 | {option}
142 |
143 | ))}
144 |
145 |
146 |
147 |
148 |
Description
149 |
153 |
154 |
155 |
156 |
Assignee
157 |
158 |
159 |
160 |
Reporter
161 |
162 |
163 |
164 | {canChange && (
165 |
170 | {deleteLoading ? "Deleting..." : "Delete Issue"}
171 |
172 | )}
173 | {(deleteError || updateError) && (
174 |
175 | {deleteError?.message || updateError?.message}
176 |
177 | )}
178 |
179 |
180 |
181 | );
182 | }
183 |
--------------------------------------------------------------------------------
/components/org-switcher.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { usePathname } from "next/navigation";
4 | import {
5 | OrganizationSwitcher,
6 | SignedIn,
7 | useOrganization,
8 | useUser,
9 | } from "@clerk/nextjs";
10 |
11 | const OrgSwitcher = () => {
12 | const { isLoaded } = useOrganization();
13 | const { isLoaded: isUserLoaded } = useUser();
14 | const pathname = usePathname();
15 |
16 | if (pathname === "/") {
17 | return null;
18 | }
19 |
20 | if (!isLoaded || !isUserLoaded) {
21 | return null;
22 | }
23 |
24 | return (
25 |
26 |
27 |
43 |
44 |
45 | );
46 | };
47 |
48 | export default OrgSwitcher;
49 |
--------------------------------------------------------------------------------
/components/theme-provider.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 |
6 | export function ThemeProvider({ children, ...props }) {
7 | return {children} ;
8 | }
9 |
--------------------------------------------------------------------------------
/components/ui/accordion.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDownIcon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef(({ className, ...props }, ref) => (
12 |
13 | ))
14 | AccordionItem.displayName = "AccordionItem"
15 |
16 | const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
17 |
18 | svg]:rotate-180",
22 | className
23 | )}
24 | {...props}>
25 | {children}
26 |
28 |
29 |
30 | ))
31 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
32 |
33 | const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => (
34 |
38 | {children}
39 |
40 | ))
41 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
42 |
43 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
44 |
--------------------------------------------------------------------------------
/components/ui/avatar.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef(({ className, ...props }, ref) => (
9 |
13 | ))
14 | Avatar.displayName = AvatarPrimitive.Root.displayName
15 |
16 | const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
17 |
21 | ))
22 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
23 |
24 | const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
25 |
32 | ))
33 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
34 |
35 | export { Avatar, AvatarImage, AvatarFallback }
36 |
--------------------------------------------------------------------------------
/components/ui/badge.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva } from "class-variance-authority";
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | function Badge({
27 | className,
28 | variant,
29 | ...props
30 | }) {
31 | return (
);
32 | }
33 |
34 | export { Badge, badgeVariants }
35 |
--------------------------------------------------------------------------------
/components/ui/button.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | );
36 |
37 | const Button = React.forwardRef(
38 | ({ className, variant, size, asChild = false, ...props }, ref) => {
39 | const Comp = asChild ? Slot : "button";
40 | return (
41 |
46 | );
47 | }
48 | );
49 | Button.displayName = "Button";
50 |
51 | export { Button, buttonVariants };
52 |
--------------------------------------------------------------------------------
/components/ui/card.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef(({ className, ...props }, ref) => (
6 |
10 | ))
11 | Card.displayName = "Card"
12 |
13 | const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
14 |
18 | ))
19 | CardHeader.displayName = "CardHeader"
20 |
21 | const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
22 |
26 | ))
27 | CardTitle.displayName = "CardTitle"
28 |
29 | const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
30 |
34 | ))
35 | CardDescription.displayName = "CardDescription"
36 |
37 | const CardContent = React.forwardRef(({ className, ...props }, ref) => (
38 |
39 | ))
40 | CardContent.displayName = "CardContent"
41 |
42 | const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
43 |
47 | ))
48 | CardFooter.displayName = "CardFooter"
49 |
50 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
51 |
--------------------------------------------------------------------------------
/components/ui/carousel.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react"
3 | import { ArrowLeftIcon, ArrowRightIcon } from "@radix-ui/react-icons"
4 | import useEmblaCarousel from "embla-carousel-react";
5 |
6 | import { cn } from "@/lib/utils"
7 | import { Button } from "@/components/ui/button"
8 |
9 | const CarouselContext = React.createContext(null)
10 |
11 | function useCarousel() {
12 | const context = React.useContext(CarouselContext)
13 |
14 | if (!context) {
15 | throw new Error("useCarousel must be used within a ")
16 | }
17 |
18 | return context
19 | }
20 |
21 | const Carousel = React.forwardRef((
22 | {
23 | orientation = "horizontal",
24 | opts,
25 | setApi,
26 | plugins,
27 | className,
28 | children,
29 | ...props
30 | },
31 | ref
32 | ) => {
33 | const [carouselRef, api] = useEmblaCarousel({
34 | ...opts,
35 | axis: orientation === "horizontal" ? "x" : "y",
36 | }, plugins)
37 | const [canScrollPrev, setCanScrollPrev] = React.useState(false)
38 | const [canScrollNext, setCanScrollNext] = React.useState(false)
39 |
40 | const onSelect = React.useCallback((api) => {
41 | if (!api) {
42 | return
43 | }
44 |
45 | setCanScrollPrev(api.canScrollPrev())
46 | setCanScrollNext(api.canScrollNext())
47 | }, [])
48 |
49 | const scrollPrev = React.useCallback(() => {
50 | api?.scrollPrev()
51 | }, [api])
52 |
53 | const scrollNext = React.useCallback(() => {
54 | api?.scrollNext()
55 | }, [api])
56 |
57 | const handleKeyDown = React.useCallback((event) => {
58 | if (event.key === "ArrowLeft") {
59 | event.preventDefault()
60 | scrollPrev()
61 | } else if (event.key === "ArrowRight") {
62 | event.preventDefault()
63 | scrollNext()
64 | }
65 | }, [scrollPrev, scrollNext])
66 |
67 | React.useEffect(() => {
68 | if (!api || !setApi) {
69 | return
70 | }
71 |
72 | setApi(api)
73 | }, [api, setApi])
74 |
75 | React.useEffect(() => {
76 | if (!api) {
77 | return
78 | }
79 |
80 | onSelect(api)
81 | api.on("reInit", onSelect)
82 | api.on("select", onSelect)
83 |
84 | return () => {
85 | api?.off("select", onSelect)
86 | };
87 | }, [api, onSelect])
88 |
89 | return (
90 | (
102 |
109 | {children}
110 |
111 | )
112 | );
113 | })
114 | Carousel.displayName = "Carousel"
115 |
116 | const CarouselContent = React.forwardRef(({ className, ...props }, ref) => {
117 | const { carouselRef, orientation } = useCarousel()
118 |
119 | return (
120 | ()
130 | );
131 | })
132 | CarouselContent.displayName = "CarouselContent"
133 |
134 | const CarouselItem = React.forwardRef(({ className, ...props }, ref) => {
135 | const { orientation } = useCarousel()
136 |
137 | return (
138 | (
)
148 | );
149 | })
150 | CarouselItem.displayName = "CarouselItem"
151 |
152 | const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
153 | const { orientation, scrollPrev, canScrollPrev } = useCarousel()
154 |
155 | return (
156 | (
166 |
167 | Previous slide
168 | )
169 | );
170 | })
171 | CarouselPrevious.displayName = "CarouselPrevious"
172 |
173 | const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => {
174 | const { orientation, scrollNext, canScrollNext } = useCarousel()
175 |
176 | return (
177 | (
187 |
188 | Next slide
189 | )
190 | );
191 | })
192 | CarouselNext.displayName = "CarouselNext"
193 |
194 | export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };
195 |
--------------------------------------------------------------------------------
/components/ui/dialog.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { Cross2Icon } from "@radix-ui/react-icons"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
18 |
25 | ))
26 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
27 |
28 | const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
29 |
30 |
31 |
38 | {children}
39 |
41 |
42 | Close
43 |
44 |
45 |
46 | ))
47 | DialogContent.displayName = DialogPrimitive.Content.displayName
48 |
49 | const DialogHeader = ({
50 | className,
51 | ...props
52 | }) => (
53 |
56 | )
57 | DialogHeader.displayName = "DialogHeader"
58 |
59 | const DialogFooter = ({
60 | className,
61 | ...props
62 | }) => (
63 |
66 | )
67 | DialogFooter.displayName = "DialogFooter"
68 |
69 | const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
70 |
74 | ))
75 | DialogTitle.displayName = DialogPrimitive.Title.displayName
76 |
77 | const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
78 |
82 | ))
83 | DialogDescription.displayName = DialogPrimitive.Description.displayName
84 |
85 | export {
86 | Dialog,
87 | DialogPortal,
88 | DialogOverlay,
89 | DialogTrigger,
90 | DialogClose,
91 | DialogContent,
92 | DialogHeader,
93 | DialogFooter,
94 | DialogTitle,
95 | DialogDescription,
96 | }
97 |
--------------------------------------------------------------------------------
/components/ui/drawer.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Drawer = ({
9 | shouldScaleBackground = true,
10 | ...props
11 | }) => (
12 |
13 | )
14 | Drawer.displayName = "Drawer"
15 |
16 | const DrawerTrigger = DrawerPrimitive.Trigger
17 |
18 | const DrawerPortal = DrawerPrimitive.Portal
19 |
20 | const DrawerClose = DrawerPrimitive.Close
21 |
22 | const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => (
23 |
27 | ))
28 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
29 |
30 | const DrawerContent = React.forwardRef(({ className, children, ...props }, ref) => (
31 |
32 |
33 |
40 |
41 | {children}
42 |
43 |
44 | ))
45 | DrawerContent.displayName = "DrawerContent"
46 |
47 | const DrawerHeader = ({
48 | className,
49 | ...props
50 | }) => (
51 |
54 | )
55 | DrawerHeader.displayName = "DrawerHeader"
56 |
57 | const DrawerFooter = ({
58 | className,
59 | ...props
60 | }) => (
61 |
62 | )
63 | DrawerFooter.displayName = "DrawerFooter"
64 |
65 | const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => (
66 |
70 | ))
71 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
72 |
73 | const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => (
74 |
78 | ))
79 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
80 |
81 | export {
82 | Drawer,
83 | DrawerPortal,
84 | DrawerOverlay,
85 | DrawerTrigger,
86 | DrawerClose,
87 | DrawerContent,
88 | DrawerHeader,
89 | DrawerFooter,
90 | DrawerTitle,
91 | DrawerDescription,
92 | }
93 |
--------------------------------------------------------------------------------
/components/ui/input.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef(({ className, type, ...props }, ref) => {
6 | return (
7 | ( )
15 | );
16 | })
17 | Input.displayName = "Input"
18 |
19 | export { Input }
20 |
--------------------------------------------------------------------------------
/components/ui/popover.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverAnchor = PopoverPrimitive.Anchor
13 |
14 | const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
15 |
16 |
25 |
26 | ))
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
28 |
29 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
30 |
--------------------------------------------------------------------------------
/components/ui/select.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import {
5 | CaretSortIcon,
6 | CheckIcon,
7 | ChevronDownIcon,
8 | ChevronUpIcon,
9 | } from "@radix-ui/react-icons";
10 | import * as SelectPrimitive from "@radix-ui/react-select";
11 |
12 | import { cn } from "@/lib/utils";
13 |
14 | const Select = SelectPrimitive.Root;
15 |
16 | const SelectGroup = SelectPrimitive.Group;
17 |
18 | const SelectValue = SelectPrimitive.Value;
19 |
20 | const SelectTrigger = React.forwardRef(
21 | ({ className, children, ...props }, ref) => (
22 | span]:line-clamp-1",
26 | className
27 | )}
28 | {...props}
29 | >
30 | {children}
31 |
32 |
33 |
34 |
35 | )
36 | );
37 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
38 |
39 | const SelectScrollUpButton = React.forwardRef(
40 | ({ className, ...props }, ref) => (
41 |
49 |
50 |
51 | )
52 | );
53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
54 |
55 | const SelectScrollDownButton = React.forwardRef(
56 | ({ className, ...props }, ref) => (
57 |
65 |
66 |
67 | )
68 | );
69 | SelectScrollDownButton.displayName =
70 | SelectPrimitive.ScrollDownButton.displayName;
71 |
72 | const SelectContent = React.forwardRef(
73 | ({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | )
100 | );
101 | SelectContent.displayName = SelectPrimitive.Content.displayName;
102 |
103 | const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
104 |
109 | ));
110 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
111 |
112 | const SelectItem = React.forwardRef(
113 | ({ className, children, ...props }, ref) => (
114 |
122 |
123 |
124 |
125 |
126 |
127 | {children}
128 |
129 | )
130 | );
131 | SelectItem.displayName = SelectPrimitive.Item.displayName;
132 |
133 | const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
134 |
139 | ));
140 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
141 |
142 | export {
143 | Select,
144 | SelectGroup,
145 | SelectValue,
146 | SelectTrigger,
147 | SelectContent,
148 | SelectLabel,
149 | SelectItem,
150 | SelectSeparator,
151 | SelectScrollUpButton,
152 | SelectScrollDownButton,
153 | };
154 |
--------------------------------------------------------------------------------
/components/ui/sonner.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useTheme } from "next-themes"
3 | import { Toaster as Sonner } from "sonner"
4 |
5 | const Toaster = ({
6 | ...props
7 | }) => {
8 | const { theme = "system" } = useTheme()
9 |
10 | return (
11 | ( )
26 | );
27 | }
28 |
29 | export { Toaster }
30 |
--------------------------------------------------------------------------------
/components/ui/tabs.jsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef(({ className, ...props }, ref) => (
11 |
18 | ))
19 | TabsList.displayName = TabsPrimitive.List.displayName
20 |
21 | const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
22 |
29 | ))
30 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
31 |
32 | const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
33 |
40 | ))
41 | TabsContent.displayName = TabsPrimitive.Content.displayName
42 |
43 | export { Tabs, TabsList, TabsTrigger, TabsContent }
44 |
--------------------------------------------------------------------------------
/components/ui/textarea.jsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Textarea = React.forwardRef(({ className, ...props }, ref) => {
6 | return (
7 | ( )
14 | );
15 | })
16 | Textarea.displayName = "Textarea"
17 |
18 | export { Textarea }
19 |
--------------------------------------------------------------------------------
/components/user-avatar.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
3 |
4 | const UserAvatar = ({ user }) => {
5 | return (
6 |
7 |
8 |
9 |
10 | {user ? user.name : "?"}
11 |
12 |
13 |
14 | {user ? user.name : "Unassigned"}
15 |
16 |
17 | );
18 | };
19 |
20 | export default UserAvatar;
21 |
--------------------------------------------------------------------------------
/components/user-loading.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useOrganization, useUser } from "@clerk/nextjs";
4 | import React from "react";
5 | import { BarLoader } from "react-spinners";
6 |
7 | const UserLoading = () => {
8 | const { isLoaded } = useOrganization();
9 | const { isLoaded: isUserLoaded } = useUser();
10 |
11 | if (!isLoaded || !isUserLoaded) {
12 | return ;
13 | } else <>>;
14 | };
15 |
16 | export default UserLoading;
17 |
--------------------------------------------------------------------------------
/components/user-menu.jsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { UserButton } from "@clerk/nextjs";
4 | import { ChartNoAxesGantt } from "lucide-react";
5 |
6 | const UserMenu = () => {
7 | return (
8 |
15 |
16 | }
19 | href="/onboarding"
20 | />
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default UserMenu;
28 |
--------------------------------------------------------------------------------
/data/companies.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "amazon",
4 | "path": "/companies/amazon.svg",
5 | "id": 1
6 | },
7 | {
8 | "name": "atlassian",
9 | "path": "/companies/atlassian.svg",
10 | "id": 2
11 | },
12 | {
13 | "name": "google",
14 | "path": "/companies/google.webp",
15 | "id": 3
16 | },
17 | {
18 | "name": "ibm",
19 | "path": "/companies/ibm.svg",
20 | "id": 4
21 | },
22 | {
23 | "name": "meta",
24 | "path": "/companies/meta.svg",
25 | "id": 5
26 | },
27 | {
28 | "name": "microsoft",
29 | "path": "/companies/microsoft.webp",
30 | "id": 6
31 | },
32 | {
33 | "name": "netflix",
34 | "path": "/companies/netflix.png",
35 | "id": 7
36 | },
37 | {
38 | "name": "uber",
39 | "path": "/companies/uber.svg",
40 | "id": 8
41 | }
42 | ]
43 |
--------------------------------------------------------------------------------
/data/faqs.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "question": "What is ZCRUM?",
4 | "answer": "ZCRUM is a powerful project management tool designed to help teams organize, track, and manage their work efficiently. It combines intuitive design with robust features to streamline your workflow and boost productivity."
5 | },
6 | {
7 | "question": "How does ZCRUM compare to other project management tools?",
8 | "answer": "ZCRUM offers a unique combination of intuitive design, powerful features, and flexibility. Unlike other tools, we focus on providing a seamless experience for both agile and traditional project management methodologies, making it versatile for various team structures and project types."
9 | },
10 | {
11 | "question": "Is ZCRUM suitable for small teams?",
12 | "answer": "Absolutely! ZCRUM is designed to be scalable and flexible. It works great for small teams and can easily grow with your organization as it expands. Our user-friendly interface ensures that teams of any size can quickly adapt and start benefiting from ZCRUM's features."
13 | },
14 | {
15 | "question": "What key features does ZCRUM offer?",
16 | "answer": "ZCRUM provides a range of powerful features including intuitive Kanban boards for visualizing workflow, robust sprint planning tools for agile teams, comprehensive reporting for data-driven decisions, customizable workflows, time tracking, and team collaboration tools. These features work seamlessly together to enhance your project management experience."
17 | },
18 | {
19 | "question": "Can ZCRUM handle multiple projects simultaneously?",
20 | "answer": "Yes, ZCRUM is built to manage multiple projects concurrently. You can easily switch between projects, and get a bird's-eye view of all your ongoing work. This makes ZCRUM ideal for organizations juggling multiple projects or clients."
21 | },
22 | {
23 | "question": "Is there a learning curve for new users?",
24 | "answer": "While ZCRUM is packed with features, we've designed it with user-friendliness in mind. New users can quickly get up to speed thanks to our intuitive interface, helpful onboarding process, and comprehensive documentation."
25 | }
26 | ]
27 |
--------------------------------------------------------------------------------
/data/status.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "name": "Todo",
4 | "key": "TODO"
5 | },
6 | {
7 | "name": "In Progress",
8 | "key": "IN_PROGRESS"
9 | },
10 | {
11 | "name": "In Review",
12 | "key": "IN_REVIEW"
13 | },
14 | {
15 | "name": "Done",
16 | "key": "DONE"
17 | }
18 | ]
19 |
--------------------------------------------------------------------------------
/hooks/use-fetch.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { toast } from "sonner";
3 |
4 | const useFetch = (cb) => {
5 | const [data, setData] = useState(undefined);
6 | const [loading, setLoading] = useState(null);
7 | const [error, setError] = useState(null);
8 |
9 | const fn = async (...args) => {
10 | setLoading(true);
11 | setError(null);
12 |
13 | try {
14 | const response = await cb(...args);
15 | setData(response);
16 | setError(null);
17 | } catch (error) {
18 | setError(error);
19 | toast.error(error.message);
20 | } finally {
21 | setLoading(false);
22 | }
23 | };
24 |
25 | return { data, loading, error, fn, setData };
26 | };
27 |
28 | export default useFetch;
29 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/lib/checkUser.js:
--------------------------------------------------------------------------------
1 | import { currentUser } from "@clerk/nextjs/server";
2 | import { db } from "@/lib/prisma";
3 |
4 | export const checkUser = async () => {
5 | const user = await currentUser();
6 |
7 | if (!user) {
8 | return null;
9 | }
10 |
11 | try {
12 | const loggedInUser = await db?.user.findUnique({
13 | where: {
14 | clerkUserId: user.id,
15 | },
16 | });
17 |
18 | if (loggedInUser) {
19 | return loggedInUser;
20 | }
21 |
22 | const name = `${user.firstName} ${user.lastName}`;
23 |
24 | const newUser = await db.user.create({
25 | data: {
26 | clerkUserId: user.id,
27 | name,
28 | imageUrl: user.imageUrl,
29 | email: user.emailAddresses[0].emailAddress,
30 | },
31 | });
32 |
33 | return newUser;
34 | } catch (error) {
35 | console.log(error);
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/lib/prisma.js:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | export const db = globalThis.prisma || new PrismaClient();
4 |
5 | if (process.env.NODE_ENV !== "production") {
6 | globalThis.prisma = db;
7 | }
8 |
9 | // globalThis.prisma: This global variable ensures that the Prisma client instance is
10 | // reused across hot reloads during development. Without this, each time your application
11 | // reloads, a new instance of the Prisma client would be created, potentially leading
12 | // to connection issues.
13 |
--------------------------------------------------------------------------------
/lib/utils.js:
--------------------------------------------------------------------------------
1 | import { clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/middleware.js:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
2 | import { NextResponse } from "next/server";
3 |
4 | const isProtectedRoute = createRouteMatcher([
5 | "/onboarding(.*)",
6 | "/organisation(.*)",
7 | "/project(.*)",
8 | "/issue(.*)",
9 | "/sprint(.*)",
10 | ]);
11 |
12 | export default clerkMiddleware((auth, req) => {
13 | if (!auth().userId && isProtectedRoute(req)) {
14 | return auth().redirectToSignIn();
15 | }
16 |
17 | if (
18 | auth().userId &&
19 | !auth().orgId &&
20 | req.nextUrl.pathname !== "/onboarding" &&
21 | req.nextUrl.pathname !== "/"
22 | ) {
23 | return NextResponse.redirect(new URL("/onboarding", req.url));
24 | }
25 | });
26 |
27 | export const config = {
28 | matcher: [
29 | // Skip Next.js internals and all static files, unless found in search params
30 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
31 | // Always run for API routes
32 | "/(api|trpc)(.*)",
33 | ],
34 | };
35 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jira-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "postinstall": "prisma generate"
11 | },
12 | "dependencies": {
13 | "@clerk/nextjs": "^5.7.1",
14 | "@clerk/themes": "^2.1.35",
15 | "@hello-pangea/dnd": "^17.0.0",
16 | "@hookform/resolvers": "^3.9.0",
17 | "@prisma/client": "^5.20.0",
18 | "@radix-ui/react-accordion": "^1.2.1",
19 | "@radix-ui/react-avatar": "^1.1.1",
20 | "@radix-ui/react-dialog": "^1.1.2",
21 | "@radix-ui/react-icons": "^1.3.0",
22 | "@radix-ui/react-popover": "^1.1.2",
23 | "@radix-ui/react-select": "^2.1.2",
24 | "@radix-ui/react-slot": "^1.1.0",
25 | "@radix-ui/react-tabs": "^1.1.1",
26 | "@uiw/react-md-editor": "^4.0.4",
27 | "class-variance-authority": "^0.7.0",
28 | "clsx": "^2.1.1",
29 | "date-fns": "^4.1.0",
30 | "embla-carousel-autoplay": "^8.3.0",
31 | "embla-carousel-react": "^8.3.0",
32 | "lucide-react": "^0.447.0",
33 | "next": "14.2.14",
34 | "next-themes": "^0.3.0",
35 | "react": "^18",
36 | "react-day-picker": "^9.1.3",
37 | "react-dom": "^18",
38 | "react-hook-form": "^7.53.0",
39 | "react-spinners": "^0.14.1",
40 | "sonner": "^1.5.0",
41 | "tailwind-merge": "^2.5.2",
42 | "tailwindcss-animate": "^1.0.7",
43 | "vaul": "^1.0.0",
44 | "zod": "^3.23.8"
45 | },
46 | "devDependencies": {
47 | "eslint": "^8",
48 | "eslint-config-next": "14.2.14",
49 | "postcss": "^8",
50 | "prisma": "^5.20.0",
51 | "tailwindcss": "^3.4.1"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/prisma/migrations/20241008105335_created_models/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "SprintStatus" AS ENUM ('PLANNED', 'ACTIVE', 'COMPLETED');
3 |
4 | -- CreateEnum
5 | CREATE TYPE "IssueStatus" AS ENUM ('TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE');
6 |
7 | -- CreateEnum
8 | CREATE TYPE "IssuePriority" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'URGENT');
9 |
10 | -- CreateTable
11 | CREATE TABLE "User" (
12 | "id" TEXT NOT NULL,
13 | "clerkId" TEXT NOT NULL,
14 | "email" TEXT NOT NULL,
15 | "name" TEXT,
16 | "imageUrl" TEXT,
17 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18 | "updatedAt" TIMESTAMP(3) NOT NULL,
19 |
20 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
21 | );
22 |
23 | -- CreateTable
24 | CREATE TABLE "Project" (
25 | "id" TEXT NOT NULL,
26 | "name" TEXT NOT NULL,
27 | "key" TEXT NOT NULL,
28 | "description" TEXT,
29 | "organizationId" TEXT NOT NULL,
30 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
31 | "updatedAt" TIMESTAMP(3) NOT NULL,
32 |
33 | CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
34 | );
35 |
36 | -- CreateTable
37 | CREATE TABLE "Sprint" (
38 | "id" TEXT NOT NULL,
39 | "name" TEXT NOT NULL,
40 | "startDate" TIMESTAMP(3) NOT NULL,
41 | "endDate" TIMESTAMP(3) NOT NULL,
42 | "status" "SprintStatus" NOT NULL DEFAULT 'PLANNED',
43 | "projectId" TEXT NOT NULL,
44 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
45 | "updatedAt" TIMESTAMP(3) NOT NULL,
46 |
47 | CONSTRAINT "Sprint_pkey" PRIMARY KEY ("id")
48 | );
49 |
50 | -- CreateTable
51 | CREATE TABLE "Issue" (
52 | "id" TEXT NOT NULL,
53 | "title" TEXT NOT NULL,
54 | "description" TEXT,
55 | "status" "IssueStatus" NOT NULL,
56 | "order" INTEGER NOT NULL,
57 | "priority" "IssuePriority" NOT NULL,
58 | "assigneeId" TEXT,
59 | "reporterId" TEXT NOT NULL,
60 | "projectId" TEXT NOT NULL,
61 | "sprintId" TEXT,
62 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
63 | "updatedAt" TIMESTAMP(3) NOT NULL,
64 |
65 | CONSTRAINT "Issue_pkey" PRIMARY KEY ("id")
66 | );
67 |
68 | -- CreateIndex
69 | CREATE UNIQUE INDEX "User_clerkId_key" ON "User"("clerkId");
70 |
71 | -- CreateIndex
72 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
73 |
74 | -- CreateIndex
75 | CREATE UNIQUE INDEX "Project_organizationId_key_key" ON "Project"("organizationId", "key");
76 |
77 | -- CreateIndex
78 | CREATE INDEX "Issue_status_order_idx" ON "Issue"("status", "order");
79 |
80 | -- AddForeignKey
81 | ALTER TABLE "Sprint" ADD CONSTRAINT "Sprint_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
82 |
83 | -- AddForeignKey
84 | ALTER TABLE "Issue" ADD CONSTRAINT "Issue_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
85 |
86 | -- AddForeignKey
87 | ALTER TABLE "Issue" ADD CONSTRAINT "Issue_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
88 |
89 | -- AddForeignKey
90 | ALTER TABLE "Issue" ADD CONSTRAINT "Issue_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
91 |
92 | -- AddForeignKey
93 | ALTER TABLE "Issue" ADD CONSTRAINT "Issue_sprintId_fkey" FOREIGN KEY ("sprintId") REFERENCES "Sprint"("id") ON DELETE SET NULL ON UPDATE CASCADE;
94 |
--------------------------------------------------------------------------------
/prisma/migrations/20241008110923_update_user_table/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `clerkId` on the `User` table. All the data in the column will be lost.
5 | - A unique constraint covering the columns `[clerkUserId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
6 | - Added the required column `clerkUserId` to the `User` table without a default value. This is not possible if the table is not empty.
7 |
8 | */
9 | -- DropIndex
10 | DROP INDEX "User_clerkId_key";
11 |
12 | -- AlterTable
13 | ALTER TABLE "User" DROP COLUMN "clerkId",
14 | ADD COLUMN "clerkUserId" TEXT NOT NULL;
15 |
16 | -- CreateIndex
17 | CREATE UNIQUE INDEX "User_clerkUserId_key" ON "User"("clerkUserId");
18 |
--------------------------------------------------------------------------------
/prisma/migrations/20241021055013_add_cascading_deletes/migration.sql:
--------------------------------------------------------------------------------
1 | -- DropForeignKey
2 | ALTER TABLE "Issue" DROP CONSTRAINT "Issue_projectId_fkey";
3 |
4 | -- DropForeignKey
5 | ALTER TABLE "Sprint" DROP CONSTRAINT "Sprint_projectId_fkey";
6 |
7 | -- AddForeignKey
8 | ALTER TABLE "Sprint" ADD CONSTRAINT "Sprint_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
9 |
10 | -- AddForeignKey
11 | ALTER TABLE "Issue" ADD CONSTRAINT "Issue_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
12 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("DATABASE_URL")
8 | }
9 |
10 | model User {
11 | id String @id @default(cuid())
12 | clerkUserId String @unique
13 | email String @unique
14 | name String?
15 | imageUrl String?
16 | createdIssues Issue[] @relation("Reporter")
17 | assignedIssues Issue[] @relation("Assignee")
18 | createdAt DateTime @default(now())
19 | updatedAt DateTime @updatedAt
20 | }
21 |
22 | model Project {
23 | id String @id @default(cuid())
24 | name String
25 | key String
26 | description String?
27 | organizationId String // This will store the Clerk organization ID
28 | sprints Sprint[]
29 | issues Issue[]
30 | createdAt DateTime @default(now())
31 | updatedAt DateTime @updatedAt
32 |
33 | @@unique([organizationId, key])
34 | }
35 |
36 | model Sprint {
37 | id String @id @default(cuid())
38 | name String @unique
39 | startDate DateTime
40 | endDate DateTime
41 | status SprintStatus @default(PLANNED)
42 | project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
43 | projectId String
44 | issues Issue[]
45 | createdAt DateTime @default(now())
46 | updatedAt DateTime @updatedAt
47 | }
48 |
49 | model Issue {
50 | id String @id @default(cuid())
51 | title String
52 | description String?
53 | status IssueStatus
54 | order Int // This will store the order within its status column
55 | priority IssuePriority
56 | assignee User? @relation("Assignee", fields: [assigneeId], references: [id])
57 | assigneeId String?
58 | reporter User @relation("Reporter", fields: [reporterId], references: [id])
59 | reporterId String
60 | project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
61 | projectId String
62 | sprint Sprint? @relation(fields: [sprintId], references: [id], onDelete: SetNull)
63 | sprintId String?
64 | createdAt DateTime @default(now())
65 | updatedAt DateTime @updatedAt
66 |
67 | @@index([status, order])
68 | }
69 |
70 | enum SprintStatus {
71 | PLANNED
72 | ACTIVE
73 | COMPLETED
74 | }
75 |
76 | enum IssueStatus {
77 | TODO
78 | IN_PROGRESS
79 | IN_REVIEW
80 | DONE
81 | }
82 |
83 | enum IssuePriority {
84 | LOW
85 | MEDIUM
86 | HIGH
87 | URGENT
88 | }
--------------------------------------------------------------------------------
/public/companies/amazon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/companies/atlassian.svg:
--------------------------------------------------------------------------------
1 | Atlassian logo
--------------------------------------------------------------------------------
/public/companies/google.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/jira-clone/38beaee1a1f72ac8d5f30a316366f5b493f5c4a0/public/companies/google.webp
--------------------------------------------------------------------------------
/public/companies/ibm.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/companies/meta.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Logo of Meta Platforms -- Graphic created by Detmar Owen
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
25 |
26 |
--------------------------------------------------------------------------------
/public/companies/microsoft.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/jira-clone/38beaee1a1f72ac8d5f30a316366f5b493f5c4a0/public/companies/microsoft.webp
--------------------------------------------------------------------------------
/public/companies/netflix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/jira-clone/38beaee1a1f72ac8d5f30a316366f5b493f5c4a0/public/companies/netflix.png
--------------------------------------------------------------------------------
/public/companies/uber.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/jira-clone/38beaee1a1f72ac8d5f30a316366f5b493f5c4a0/public/logo.png
--------------------------------------------------------------------------------
/public/logo2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/piyush-eon/jira-clone/38beaee1a1f72ac8d5f30a316366f5b493f5c4a0/public/logo2.png
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | colors: {
12 | background: 'hsl(var(--background))',
13 | foreground: 'hsl(var(--foreground))',
14 | card: {
15 | DEFAULT: 'hsl(var(--card))',
16 | foreground: 'hsl(var(--card-foreground))'
17 | },
18 | popover: {
19 | DEFAULT: 'hsl(var(--popover))',
20 | foreground: 'hsl(var(--popover-foreground))'
21 | },
22 | primary: {
23 | DEFAULT: 'hsl(var(--primary))',
24 | foreground: 'hsl(var(--primary-foreground))'
25 | },
26 | secondary: {
27 | DEFAULT: 'hsl(var(--secondary))',
28 | foreground: 'hsl(var(--secondary-foreground))'
29 | },
30 | muted: {
31 | DEFAULT: 'hsl(var(--muted))',
32 | foreground: 'hsl(var(--muted-foreground))'
33 | },
34 | accent: {
35 | DEFAULT: 'hsl(var(--accent))',
36 | foreground: 'hsl(var(--accent-foreground))'
37 | },
38 | destructive: {
39 | DEFAULT: 'hsl(var(--destructive))',
40 | foreground: 'hsl(var(--destructive-foreground))'
41 | },
42 | border: 'hsl(var(--border))',
43 | input: 'hsl(var(--input))',
44 | ring: 'hsl(var(--ring))',
45 | chart: {
46 | '1': 'hsl(var(--chart-1))',
47 | '2': 'hsl(var(--chart-2))',
48 | '3': 'hsl(var(--chart-3))',
49 | '4': 'hsl(var(--chart-4))',
50 | '5': 'hsl(var(--chart-5))'
51 | }
52 | },
53 | borderRadius: {
54 | lg: 'var(--radius)',
55 | md: 'calc(var(--radius) - 2px)',
56 | sm: 'calc(var(--radius) - 4px)'
57 | },
58 | keyframes: {
59 | 'accordion-down': {
60 | from: {
61 | height: '0'
62 | },
63 | to: {
64 | height: 'var(--radix-accordion-content-height)'
65 | }
66 | },
67 | 'accordion-up': {
68 | from: {
69 | height: 'var(--radix-accordion-content-height)'
70 | },
71 | to: {
72 | height: '0'
73 | }
74 | }
75 | },
76 | animation: {
77 | 'accordion-down': 'accordion-down 0.2s ease-out',
78 | 'accordion-up': 'accordion-up 0.2s ease-out'
79 | }
80 | }
81 | },
82 | plugins: [require("tailwindcss-animate")],
83 | };
84 |
--------------------------------------------------------------------------------