Repository: phuc-mai/academy
Branch: master
Commit: d3b5fe3aff7b
Files: 96
Total size: 152.7 KB
Directory structure:
gitextract_258s52nm/
├── .eslintrc.json
├── .gitignore
├── README.md
├── app/
│ ├── (auth)/
│ │ ├── layout.tsx
│ │ ├── sign-in/
│ │ │ └── [[...sign-in]]/
│ │ │ └── page.tsx
│ │ └── sign-up/
│ │ └── [[...sign-up]]/
│ │ └── page.tsx
│ ├── (course)/
│ │ └── courses/
│ │ └── [courseId]/
│ │ ├── layout.tsx
│ │ ├── overview/
│ │ │ └── page.tsx
│ │ └── sections/
│ │ └── [sectionId]/
│ │ └── page.tsx
│ ├── (home)/
│ │ ├── categories/
│ │ │ └── [categoryId]/
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── learning/
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ └── search/
│ │ └── page.tsx
│ ├── (instructor)/
│ │ ├── instructor/
│ │ │ ├── courses/
│ │ │ │ ├── [courseId]/
│ │ │ │ │ ├── basic/
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ └── sections/
│ │ │ │ │ ├── [sectionId]/
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── create-course/
│ │ │ │ └── page.tsx
│ │ │ └── performance/
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── actions/
│ │ ├── getCourses.tsx
│ │ └── getPerformance.ts
│ ├── api/
│ │ ├── courses/
│ │ │ ├── [courseId]/
│ │ │ │ ├── checkout/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── publish/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── route.ts
│ │ │ │ ├── sections/
│ │ │ │ │ ├── [sectionId]/
│ │ │ │ │ │ ├── progress/
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ ├── publish/
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ ├── resources/
│ │ │ │ │ │ │ ├── [resourceId]/
│ │ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ ├── route.ts
│ │ │ │ │ │ └── unpublish/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── reorder/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ └── unpublish/
│ │ │ │ └── route.ts
│ │ │ └── route.ts
│ │ ├── uploadthing/
│ │ │ ├── core.ts
│ │ │ └── route.ts
│ │ └── webhook/
│ │ └── route.ts
│ ├── globals.css
│ └── layout.tsx
├── components/
│ ├── courses/
│ │ ├── Columns.tsx
│ │ ├── CourseCard.tsx
│ │ ├── CreateCourseForm.tsx
│ │ └── EditCourseForm.tsx
│ ├── custom/
│ │ ├── AlertBanner.tsx
│ │ ├── Categories.tsx
│ │ ├── ComboBox.tsx
│ │ ├── DataTable.tsx
│ │ ├── Delete.tsx
│ │ ├── FileUpload.tsx
│ │ ├── PublishButton.tsx
│ │ ├── ReadText.tsx
│ │ └── RichEditor.tsx
│ ├── layout/
│ │ ├── CourseSideBar.tsx
│ │ ├── SectionMenu.tsx
│ │ ├── Sidebar.tsx
│ │ └── Topbar.tsx
│ ├── performance/
│ │ ├── Chart.tsx
│ │ └── DataCard.tsx
│ ├── providers/
│ │ └── ToasterProvider.tsx
│ ├── sections/
│ │ ├── CreateSectionForm.tsx
│ │ ├── EditSectionForm.tsx
│ │ ├── ProgressButton.tsx
│ │ ├── ResourceForm.tsx
│ │ ├── SectionList.tsx
│ │ └── SectionsDetails.tsx
│ └── ui/
│ ├── alert-dialog.tsx
│ ├── alert.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── command.tsx
│ ├── dialog.tsx
│ ├── form.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── popover.tsx
│ ├── progress.tsx
│ ├── sheet.tsx
│ ├── switch.tsx
│ └── table.tsx
├── components.json
├── lib/
│ ├── db.ts
│ ├── formatPrice.ts
│ ├── stripe.ts
│ ├── uploadthing.ts
│ └── utils.ts
├── middleware.ts
├── next.config.mjs
├── package.json
├── postcss.config.mjs
├── prisma/
│ └── schema.prisma
├── scripts/
│ └── seed.ts
├── tailwind.config.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"extends": "next/core-web-vitals"
}
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
================================================
FILE: README.md
================================================
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
================================================
FILE: app/(auth)/layout.tsx
================================================
const AuthLayout = ({ children }: { children: React.ReactNode }) => {
return (
{children}
)
}
export default AuthLayout
================================================
FILE: app/(auth)/sign-in/[[...sign-in]]/page.tsx
================================================
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return ;
}
================================================
FILE: app/(auth)/sign-up/[[...sign-up]]/page.tsx
================================================
import { SignUp } from "@clerk/nextjs";
export default function Page() {
return ;
}
================================================
FILE: app/(course)/courses/[courseId]/layout.tsx
================================================
import CourseSideBar from "@/components/layout/CourseSideBar";
import Topbar from "@/components/layout/Topbar";
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
const CourseDetailsLayout = async ({
children,
params,
}: {
children: React.ReactNode;
params: { courseId: string };
}) => {
const { userId } = auth();
if (!userId) {
return redirect("/sign-in");
}
const course = await db.course.findUnique({
where: {
id: params.courseId,
},
include: {
sections: {
where: {
isPublished: true,
},
orderBy: {
position: "asc",
},
},
},
});
if (!course) {
return redirect("/");
}
return (
);
};
export default CourseDetailsLayout;
================================================
FILE: app/(course)/courses/[courseId]/overview/page.tsx
================================================
import { clerkClient } from "@clerk/nextjs/server";
import Image from "next/image";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import ReadText from "@/components/custom/ReadText";
import SectionMenu from "@/components/layout/SectionMenu";
const CourseOverview = async ({ params }: { params: { courseId: string } }) => {
const course = await db.course.findUnique({
where: {
id: params.courseId,
isPublished: true,
},
include: {
sections: {
where: {
isPublished: true,
},
},
},
});
if (!course) {
return redirect("/");
}
const instructor = await clerkClient.users.getUser(course.instructorId);
let level;
if (course.levelId) {
level = await db.level.findUnique({
where: {
id: course.levelId,
},
});
}
return (
{course.title}
{course.subtitle}
Instructor:
{instructor.fullName}
);
};
export default CourseOverview;
================================================
FILE: app/(course)/courses/[courseId]/sections/[sectionId]/page.tsx
================================================
import SectionsDetails from "@/components/sections/SectionsDetails";
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { Resource } from "@prisma/client";
import { redirect } from "next/navigation";
const SectionDetailsPage = async ({
params,
}: {
params: { courseId: string; sectionId: string };
}) => {
const { courseId, sectionId } = params;
const { userId } = auth();
if (!userId) {
return redirect("/sign-in");
}
const course = await db.course.findUnique({
where: {
id: courseId,
isPublished: true,
},
include: {
sections: {
where: {
isPublished: true,
},
},
},
});
if (!course) {
return redirect("/");
}
const section = await db.section.findUnique({
where: {
id: sectionId,
courseId,
isPublished: true,
},
});
if (!section) {
return redirect(`/courses/${courseId}/overview`);
}
const purchase = await db.purchase.findUnique({
where: {
customerId_courseId: {
customerId: userId,
courseId,
},
},
});
let muxData = null;
let resources: Resource[] = [];
if (section.isFree || purchase) {
muxData = await db.muxData.findUnique({
where: {
sectionId,
},
});
}
if (purchase) {
resources = await db.resource.findMany({
where: {
sectionId,
},
});
}
const progress = await db.progress.findUnique({
where: {
studentId_sectionId: {
studentId: userId,
sectionId,
},
},
});
return (
);
};
export default SectionDetailsPage;
================================================
FILE: app/(home)/categories/[categoryId]/page.tsx
================================================
import getCoursesByCategory from "@/app/actions/getCourses";
import CourseCard from "@/components/courses/CourseCard";
import Categories from "@/components/custom/Categories";
import { db } from "@/lib/db";
const CoursesByCategory = async ({
params,
}: {
params: { categoryId: string };
}) => {
const categories = await db.category.findMany({
orderBy: {
name: "asc",
},
});
const courses = await getCoursesByCategory(params.categoryId);
return (
{courses.map((course) => (
))}
);
};
export default CoursesByCategory;
================================================
FILE: app/(home)/layout.tsx
================================================
import Topbar from "@/components/layout/Topbar"
const HomeLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
{children}
>
)
}
export default HomeLayout
================================================
FILE: app/(home)/learning/page.tsx
================================================
import CourseCard from "@/components/courses/CourseCard"
import { db } from "@/lib/db"
import { auth } from "@clerk/nextjs/server"
import { redirect } from "next/navigation"
const LearningPage = async () => {
const { userId } = auth()
if (!userId) {
return redirect('/sign-in')
}
const purchasedCourses = await db.purchase.findMany({
where: {
customerId: userId,
},
select: {
course: {
include: {
category: true,
subCategory: true,
sections: {
where: {
isPublished: true,
},
}
}
}
}
})
return (
Your courses
{purchasedCourses.map((purchase) => (
))}
)
}
export default LearningPage
================================================
FILE: app/(home)/page.tsx
================================================
import { db } from "@/lib/db";
import getCoursesByCategory from "../actions/getCourses";
import Categories from "@/components/custom/Categories";
import CourseCard from "@/components/courses/CourseCard";
export default async function Home() {
const categories = await db.category.findMany({
orderBy: {
name: "asc",
},
include: {
subCategories: {
orderBy: {
name: "asc",
},
},
},
});
const courses = await getCoursesByCategory(null);
return (
{courses.map((course) => (
))}
);
}
================================================
FILE: app/(home)/search/page.tsx
================================================
import CourseCard from "@/components/courses/CourseCard";
import { db } from "@/lib/db"
const SearchPage = async ({ searchParams }: { searchParams: { query: string }}) => {
const queryText = searchParams.query || ''
const courses = await db.course.findMany({
where: {
isPublished: true,
OR: [
{ title: { contains: queryText } },
{ category: { name: { contains: queryText } }},
{ subCategory: { name: { contains: queryText } }}
]
},
include: {
category: true,
subCategory: true,
level: true,
sections: {
where: {
isPublished: true,
}
}
},
orderBy: {
createdAt: 'desc'
}
});
return (
Recommended courses for {queryText}
{courses.map((course) => (
))}
)
}
export default SearchPage
================================================
FILE: app/(instructor)/instructor/courses/[courseId]/basic/page.tsx
================================================
import EditCourseForm from "@/components/courses/EditCourseForm";
import AlertBanner from "@/components/custom/AlertBanner";
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
const CourseBasics = async ({ params }: { params: { courseId: string } }) => {
const { userId } = auth();
if (!userId) {
return redirect("/sign-in");
}
const course = await db.course.findUnique({
where: {
id: params.courseId,
instructorId: userId,
},
include: {
sections: true,
},
});
if (!course) {
return redirect("/instructor/courses");
}
const categories = await db.category.findMany({
orderBy: {
name: "asc",
},
include: {
subCategories: true,
},
});
const levels = await db.level.findMany();
const requiredFields = [
course.title,
course.description,
course.categoryId,
course.subCategoryId,
course.levelId,
course.imageUrl,
course.price,
course.sections.some((section) => section.isPublished),
];
const requiredFieldsCount = requiredFields.length;
const missingFields = requiredFields.filter((field) => !Boolean(field));
const missingFieldsCount = missingFields.length;
const isCompleted = requiredFields.every(Boolean);
return (
({
label: category.name,
value: category.id,
subCategories: category.subCategories.map((subcategory) => ({
label: subcategory.name,
value: subcategory.id,
})),
}))}
levels={levels.map((level) => ({
label: level.name,
value: level.id,
}))}
isCompleted={isCompleted}
/>
);
};
export default CourseBasics;
================================================
FILE: app/(instructor)/instructor/courses/[courseId]/sections/[sectionId]/page.tsx
================================================
import AlertBanner from "@/components/custom/AlertBanner";
import EditSectionForm from "@/components/sections/EditSectionForm";
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
const SectionDetailsPage = async ({
params,
}: {
params: { courseId: string; sectionId: string };
}) => {
const { userId } = auth();
if (!userId) {
return redirect("/sign-in");
}
const course = await db.course.findUnique({
where: {
id: params.courseId,
instructorId: userId,
},
});
if (!course) {
return redirect("/instructor/courses");
}
const section = await db.section.findUnique({
where: {
id: params.sectionId,
courseId: params.courseId,
},
include: {
muxData: true,
resources: true,
},
});
if (!section) {
return redirect(`/instructor/courses/${params.courseId}/sections`);
}
const requiredFields = [section.title, section.description, section.videoUrl];
const requiredFieldsCount = requiredFields.length;
const missingFields = requiredFields.filter((field) => !Boolean(field)); // Return falsy values: undefined, null, 0, false, NaN, ''
const missingFieldsCount = missingFields.length;
const isCompleted = requiredFields.every(Boolean);
return (
);
};
export default SectionDetailsPage;
================================================
FILE: app/(instructor)/instructor/courses/[courseId]/sections/page.tsx
================================================
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import CreateSectionForm from "@/components/sections/CreateSectionForm";
import { db } from "@/lib/db";
const CourseCurriculumPage = async ({ params }: { params: { courseId: string }}) => {
const { userId } = auth()
if (!userId) {
return redirect("/sign-in")
}
const course = await db.course.findUnique({
where: {
id: params.courseId,
instructorId: userId,
},
include: {
sections: {
orderBy: {
position: "asc",
},
},
},
});
if (!course) {
return redirect("/instructor/courses")
}
return (
);
}
export default CourseCurriculumPage;
================================================
FILE: app/(instructor)/instructor/courses/page.tsx
================================================
import { auth } from "@clerk/nextjs/server";
import Link from "next/link";
import { redirect } from "next/navigation";
import { Button } from "@/components/ui/button";
import { db } from "@/lib/db";
import { DataTable } from "@/components/custom/DataTable";
import { columns } from "@/components/courses/Columns";
const CoursesPage = async () => {
const { userId } = auth();
if (!userId) {
return redirect("/sign-in");
}
const courses = await db.course.findMany({
where: {
instructorId: userId,
},
orderBy: {
createdAt: "desc",
},
});
return (
);
};
export default CoursesPage;
================================================
FILE: app/(instructor)/instructor/create-course/page.tsx
================================================
import CreateCourseForm from "@/components/courses/CreateCourseForm"
import { db } from "@/lib/db"
const CreateCoursePage = async () => {
const categories = await db.category.findMany({
orderBy: {
name: "asc"
},
include: {
subCategories: true
}
})
return (
({
label: category.name,
value: category.id,
subCategories: category.subCategories.map((subcategory) => ({
label: subcategory.name,
value: subcategory.id
}))
}))} />
)
}
export default CreateCoursePage
================================================
FILE: app/(instructor)/instructor/performance/page.tsx
================================================
import { getPerformance } from "@/app/actions/getPerformance"
import Chart from "@/components/performance/Chart"
import DataCard from "@/components/performance/DataCard"
import { auth } from "@clerk/nextjs/server"
import { redirect } from "next/navigation"
const PerformancePage = async () => {
const { userId } = auth()
if (!userId) {
return redirect("/sign-in")
}
const { data, totalRevenue, totalSales } = await getPerformance(userId)
return (
)
}
export default PerformancePage
================================================
FILE: app/(instructor)/layout.tsx
================================================
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import Topbar from "@/components/layout/Topbar";
import Sidebar from "@/components/layout/Sidebar";
const InstructorLayout = ({ children }: { children: React.ReactNode }) => {
const { userId } = auth()
if (!userId) {
return redirect("/sign-in")
}
return (
);
};
export default InstructorLayout;
================================================
FILE: app/actions/getCourses.tsx
================================================
import { db } from "@/lib/db"
import { Course } from "@prisma/client"
const getCoursesByCategory = async (categoryId: string | null): Promise => {
const whereClause: any = {
...(categoryId ? { categoryId, isPublished: true } : { isPublished: true }),
}
const courses = await db.course.findMany({
where: whereClause,
include: {
category: true,
subCategory: true,
level: true,
sections: {
where: {
isPublished: true,
}
},
},
orderBy: {
createdAt: "desc",
},
})
return courses
}
export default getCoursesByCategory
================================================
FILE: app/actions/getPerformance.ts
================================================
import { db } from "@/lib/db";
import { Course, Purchase } from "@prisma/client";
type PurchaseWithCourse = Purchase & { course: Course };
const groupByCourse = (purchases: PurchaseWithCourse[]) => {
const grouped: { [courseTitle: string]: { total: number; count: number } } =
{};
purchases.forEach((purchase) => {
const courseTitle = purchase.course.title;
if (!grouped[courseTitle]) {
grouped[courseTitle] = { total: 0, count: 0 };
}
grouped[courseTitle].total += purchase.course.price!;
grouped[courseTitle].count += 1;
});
return grouped;
};
export const getPerformance = async (userId: string) => {
try {
const purchases = await db.purchase.findMany({
where: { course: { instructorId: userId } },
include: { course: true },
});
const groupedEarnings = groupByCourse(purchases);
const data = Object.entries(groupedEarnings).map(
([courseTitle, { total, count }]) => ({
name: courseTitle,
total,
count,
})
);
const totalRevenue = data.reduce((acc, current) => acc + current.total, 0);
const totalSales = purchases.length
return {
data,
totalRevenue,
totalSales,
};
} catch (err) {
console.log("[getPerformance]", err);
return {
data: [],
totalRevenue: 0,
totalSales: 0,
};
}
};
================================================
FILE: app/api/courses/[courseId]/checkout/route.ts
================================================
import Stripe from "stripe";
import { NextRequest, NextResponse } from "next/server";
import { currentUser } from "@clerk/nextjs/server";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string } }
) => {
try {
const user = await currentUser();
if (!user || !user.id || !user.emailAddresses?.[0]?.emailAddress) {
return new NextResponse("Unauthorized", { status: 401 });
}
const course = await db.course.findUnique({
where: { id: params.courseId, isPublished: true },
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const purchase = await db.purchase.findUnique({
where: {
customerId_courseId: { customerId: user.id, courseId: course.id },
},
});
if (purchase) {
return new NextResponse("Course Already Purchased", { status: 400 });
}
const line_items: Stripe.Checkout.SessionCreateParams.LineItem[] = [
{
quantity: 1,
price_data: {
currency: "cad",
product_data: {
name: course.title,
},
unit_amount: Math.round(course.price! * 100),
},
}
]
let stripeCustomer = await db.stripeCustomer.findUnique({
where: { customerId: user.id },
select: { stripeCustomerId: true },
});
if (!stripeCustomer) {
const customer = await stripe.customers.create({
email: user.emailAddresses[0].emailAddress,
});
stripeCustomer = await db.stripeCustomer.create({
data: {
customerId: user.id,
stripeCustomerId: customer.id,
},
});
}
const session = await stripe.checkout.sessions.create({
customer: stripeCustomer.stripeCustomerId,
payment_method_types: ["card"],
line_items,
mode: "payment",
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/courses/${course.id}/overview?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/courses/${course.id}/overview?canceled=true`,
metadata: {
courseId: course.id,
customerId: user.id,
}
});
return NextResponse.json({ url: session.url })
} catch (err) {
console.log("[courseId_checkout_POST]", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/[courseId]/publish/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string } }
) => {
try {
const { userId } = auth();
const { courseId } = params;
if (!userId) {
return new Response("Unauthorized", { status: 401 });
}
const course = await db.course.findUnique({
where: { id: courseId, instructorId: userId },
include: {
sections: {
include: {
muxData: true,
},
},
},
});
if (!course) {
return new Response("Course not found", { status: 404 });
}
const isPublishedSections = course.sections.some(
(section) => section.isPublished
);
if (
!course.title ||
!course.description ||
!course.categoryId ||
!course.subCategoryId ||
!course.levelId ||
!course.imageUrl ||
!course.price ||
!isPublishedSections
) {
return new NextResponse("Missing required fields", { status: 400 });
}
const pusblishedCourse = await db.course.update({
where: { id: courseId, instructorId: userId },
data: { isPublished: true },
});
return NextResponse.json(pusblishedCourse, { status: 200 });
} catch (err) {
console.log("[courseId_publish_POST]", err);
return new Response("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/[courseId]/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import Mux from "@mux/mux-node";
const { video } = new Mux({
tokenId: process.env.MUX_TOKEN_ID,
tokenSecret: process.env.MUX_TOKEN_SECRET,
});
export const PATCH = async (
req: NextRequest,
{ params }: { params: { courseId: string } }
) => {
try {
const { userId } = auth();
const { courseId } = params;
const values = await req.json();
if (!userId) {
return new Response("Unauthorized", { status: 401 });
}
const course = await db.course.update({
where: { id: courseId, instructorId: userId },
data: { ...values },
});
return NextResponse.json(course, { status: 200 });
} catch (err) {
console.error(["courseId_PATCH", err]);
return new Response("Internal Server Error", { status: 500 });
}
};
export const DELETE = async (
req: NextRequest,
{ params }: { params: { courseId: string } }
) => {
try {
const { userId } = auth();
const { courseId } = params;
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const course = await db.course.findUnique({
where: { id: courseId, instructorId: userId},
include: {
sections: {
include: {
muxData: true,
}
}
}
});
if (!course) {
return new NextResponse("Course not found", { status: 404 });
}
for (const section of course.sections) {
if (section.muxData?.assetId) {
await video.assets.delete(section.muxData.assetId);
}
}
await db.course.delete({
where: { id: courseId, instructorId: userId },
});
return new NextResponse("Course Deleted", { status: 200 });
} catch (err) {
console.error(["courseId_DELETE", err]);
return new NextResponse("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/[courseId]/sections/[sectionId]/progress/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string; sectionId: string } }
) => {
try {
const { userId } = auth();
const { isCompleted } = await req.json();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { courseId, sectionId } = params;
const course = await db.course.findUnique({
where: {
id: courseId,
},
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const section = await db.section.findUnique({
where: {
id: sectionId,
courseId,
},
});
if (!section) {
return new NextResponse("Section Not Found", { status: 404 });
}
let progress = await db.progress.findUnique({
where: {
studentId_sectionId: {
studentId: userId,
sectionId,
},
},
});
if (progress) {
progress = await db.progress.update({
where: {
studentId_sectionId: {
studentId: userId,
sectionId,
},
},
data: {
isCompleted,
},
});
} else {
progress = await db.progress.create({
data: {
studentId: userId,
sectionId,
isCompleted,
},
});
}
return NextResponse.json(progress, { status: 200 });
} catch (err) {
console.log("[sectionId_progress_POST]", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/[courseId]/sections/[sectionId]/publish/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string; sectionId: string } }
) => {
try {
const { userId } = auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { courseId, sectionId } = params;
const course = await db.course.findUnique({
where: {
id: courseId,
instructorId: userId,
},
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const section = await db.section.findUnique({
where: {
id: sectionId,
courseId,
},
});
const muxData = await db.muxData.findUnique({
where: {
sectionId,
},
});
if (!section || !muxData || !section.title || !section.description || !section.videoUrl) {
return new NextResponse("Missing required fields", { status: 400 });
}
const publishedSection = await db.section.update({
where: {
id: sectionId,
courseId,
},
data: {
isPublished: true,
},
});
return NextResponse.json(publishedSection, { status: 200 });
} catch (err) {
console.log("[section_publish_POST]", err)
return new NextResponse("Internal Server Error", { status: 500 });
}
}
================================================
FILE: app/api/courses/[courseId]/sections/[sectionId]/resources/[resourceId]/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string; sectionId: string, resourceId: string } }
) => {
try {
const { userId } = auth()
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { courseId, sectionId, resourceId } = params;
const course = await db.course.findUnique({
where: {
id: courseId,
instructorId: userId,
},
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const section = await db.section.findUnique({
where: {
id: sectionId,
courseId,
},
});
if (!section) {
return new NextResponse("Section Not Found", { status: 404 });
}
await db.resource.delete({
where: {
id: resourceId,
sectionId,
},
});
return NextResponse.json("Resource deleted", { status: 200 });
} catch (err) {
console.log("[resourceId_DELETE", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/[courseId]/sections/[sectionId]/resources/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string; sectionId: string } }
) => {
try {
const { userId } = auth()
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { courseId, sectionId } = params;
const course = await db.course.findUnique({
where: {
id: courseId,
instructorId: userId,
},
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const section = await db.section.findUnique({
where: {
id: sectionId,
courseId,
},
});
if (!section) {
return new NextResponse("Section Not Found", { status: 404 });
}
const { name, fileUrl } = await req.json();
const resource = await db.resource.create({
data: {
name,
fileUrl,
sectionId,
},
});
return NextResponse.json(resource, { status: 200 });
} catch (err) {
console.log("[resources_POST", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/[courseId]/sections/[sectionId]/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
import Mux from "@mux/mux-node";
const { video } = new Mux({
tokenId: process.env.MUX_TOKEN_ID,
tokenSecret: process.env.MUX_TOKEN_SECRET,
});
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string; sectionId: string } }
) => {
try {
const { userId } = auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const values = await req.json();
const { courseId, sectionId } = params;
const course = await db.course.findUnique({
where: {
id: courseId,
instructorId: userId,
},
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const section = await db.section.update({
where: {
id: sectionId,
courseId,
},
data: {
...values,
},
});
if (values.videoUrl) {
const existingMuxData = await db.muxData.findFirst({
where: {
sectionId,
},
});
if (existingMuxData) {
await video.assets.delete(existingMuxData.assetId);
await db.muxData.delete({
where: {
id: existingMuxData.id,
},
});
}
const asset = await video.assets.create({
input: values.videoUrl,
playback_policy: ["public"],
test: false,
});
await db.muxData.create({
data: {
assetId: asset.id,
playbackId: asset.playback_ids?.[0]?.id,
sectionId,
},
});
}
return NextResponse.json(section, { status: 200 });
} catch (err) {
console.log("[sectionId_POST]", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
};
export const DELETE = async (req: NextRequest,
{ params }: { params: { courseId: string; sectionId: string } }
) => {
try {
const { userId } = auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { courseId, sectionId } = params;
const course = await db.course.findUnique({
where: {
id: courseId,
instructorId: userId,
},
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const section = await db.section.findUnique({
where: {
id: sectionId,
courseId,
}
});
if (!section) {
return new NextResponse("Section Not Found", { status: 404 });
}
if (section.videoUrl) {
const existingMuxData = await db.muxData.findFirst({
where: {
sectionId,
},
});
if (existingMuxData) {
await video.assets.delete(existingMuxData.assetId);
await db.muxData.delete({
where: {
id: existingMuxData.id,
},
});
}
}
await db.section.delete({
where: {
id: sectionId,
courseId,
},
});
const publishedSectionsInCourse = await db.section.findMany({
where: {
courseId,
isPublished: true,
},
});
if (!publishedSectionsInCourse.length) {
await db.course.update({
where: {
id: courseId,
},
data: {
isPublished: false,
},
});
}
return new NextResponse("Section Deleted", { status: 200 });
} catch (err) {
console.log("[sectionId_DELETE]", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
================================================
FILE: app/api/courses/[courseId]/sections/[sectionId]/unpublish/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string; sectionId: string } }
) => {
try {
const { userId } = auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { courseId, sectionId } = params;
const course = await db.course.findUnique({
where: {
id: courseId,
instructorId: userId,
},
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const unpublishedSection = await db.section.update({
where: {
id: sectionId,
courseId,
},
data: {
isPublished: false,
},
});
const publishedSectionsInCourse = await db.section.findMany({
where: {
courseId,
isPublished: true,
},
});
if (publishedSectionsInCourse.length === 0) {
await db.course.update({
where: {
id: courseId,
instructorId: userId,
},
data: {
isPublished: false,
},
});
}
return NextResponse.json(unpublishedSection, { status: 200 });
} catch (err) {
console.log("[sectionId_unpublish_POST]", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
================================================
FILE: app/api/courses/[courseId]/sections/reorder/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const PUT = async (
req: NextRequest,
{ params }: { params: { courseId: string } }
) => {
try {
const { userId } = auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { list } = await req.json();
const course = await db.course.findUnique({
where: {
id: params.courseId,
instructorId: userId,
},
});
if (!course) {
return new NextResponse("Course not found", { status: 404 });
}
for (let item of list) {
await db.section.update({
where: {
id: item.id,
},
data: {
position: item.position,
},
});
}
return new NextResponse("Reorder sections successfully", { status: 200 });
} catch (err) {
console.log("[reorder_PUT]", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/[courseId]/sections/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string } }
) => {
try {
const { userId } = auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const course = await db.course.findUnique({
where: { id: params.courseId, instructorId: userId },
});
if (!course) {
return new NextResponse("Course Not Found", { status: 404 });
}
const lastSection = await db.section.findFirst({
where: { courseId: params.courseId },
orderBy: { position: "desc" },
});
const newPosition = lastSection ? lastSection.position + 1 : 0;
const { title } = await req.json();
const newSection = await db.section.create({
data: {
title,
courseId: params.courseId,
position: newPosition,
},
});
return NextResponse.json(newSection, { status: 200 });
} catch (err) {
console.log("[sections_POST]", err);
return new NextResponse("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/[courseId]/unpublish/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (
req: NextRequest,
{ params }: { params: { courseId: string } }
) => {
try {
const { userId } = auth();
const { courseId } = params;
if (!userId) {
return new Response("Unauthorized", { status: 401 });
}
const course = await db.course.findUnique({
where: { id: courseId, instructorId: userId },
});
if (!course) {
return new Response("Course not found", { status: 404 });
}
const unpusblishedCourse = await db.course.update({
where: { id: courseId, instructorId: userId },
data: { isPublished: false },
});
return NextResponse.json(unpusblishedCourse, { status: 200 });
} catch (err) {
console.log("[courseId_unpublish_POST]", err);
return new Response("Internal Server Error", { status: 500 });
}
};
================================================
FILE: app/api/courses/route.ts
================================================
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs/server";
import { NextRequest, NextResponse } from "next/server";
export const POST = async (req: NextRequest) => {
try {
const { userId } = auth()
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 })
}
const { title, categoryId, subCategoryId } = await req.json()
const newCourse = await db.course.create({
data: {
title,
categoryId,
subCategoryId,
instructorId: userId
}
})
return NextResponse.json(newCourse, {status: 200 })
} catch (err) {
console.log("[courses_POST]", err)
return new NextResponse("Internal Server Error", { status: 500 })
}
}
================================================
FILE: app/api/uploadthing/core.ts
================================================
import { auth } from "@clerk/nextjs/server";
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { UploadThingError } from "uploadthing/server";
const f = createUploadthing();
const handleAuth = () => {
const { userId } = auth();
if (!userId) throw new Error("Unauthorized");
return { userId };
};
// FileRouter for your app, can contain multiple FileRoutes
export const ourFileRouter = {
courseBanner: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } })
.middleware(handleAuth)
.onUploadComplete(() => {}),
sectionVideo: f({ video: { maxFileSize: "512GB", maxFileCount: 1 } })
.middleware(handleAuth)
.onUploadComplete(() => {}),
sectionResource: f(["text", "image", "video", "audio", "pdf"])
.middleware(handleAuth)
.onUploadComplete(() => {}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;
================================================
FILE: app/api/uploadthing/route.ts
================================================
import { createRouteHandler } from "uploadthing/next";
import { ourFileRouter } from "./core";
// Export routes for Next App Router
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
});
================================================
FILE: app/api/webhook/route.ts
================================================
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
import { headers } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
export const POST = async (req: NextRequest) => {
const rawBody = await req.text();
const signature = headers().get("Stripe-Signature") as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: any) {
return new NextResponse(`Webhook error: ${err.message}`, { status: 400 });
}
const session = event.data.object as Stripe.Checkout.Session;
const customerId = session?.metadata?.customerId;
const courseId = session?.metadata?.courseId;
if (event.type === "checkout.session.completed") {
if (!customerId || !courseId) {
return new NextResponse("Missing metadata", { status: 400 });
}
await db.purchase.create({
data: {
customerId,
courseId,
},
});
} else {
return new NextResponse(`Unhandled event type: ${event.type}`, {
status: 400,
});
}
return new NextResponse("Success", { status: 200 });
};
================================================
FILE: app/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
:root {
height: 100%;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.cl-formButtonPrimary {
color: black;
background-color: #FDAB04;
}
.cl-formButtonPrimary:hover {
background-color: rgba(253, 171, 4, 0.8);
}
@import "@uploadthing/react/styles.css";
================================================
FILE: app/layout.tsx
================================================
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import "./globals.css";
import ToasterProvider from "@/components/providers/ToasterProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Tech Vision Academy",
description: "Empowering minds, shaping future",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
{children}
);
}
================================================
FILE: components/courses/Columns.tsx
================================================
"use client";
import { Course } from "@prisma/client";
import { ColumnDef } from "@tanstack/react-table";
import { Pencil } from "lucide-react";
import Link from "next/link";
import { ArrowUpDown } from "lucide-react";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
export const columns: ColumnDef[] = [
{
accessorKey: "title", // course.title
header: ({ column }) => {
return (
);
},
},
{
accessorKey: "price",
header: ({ column }) => {
return (
);
},
cell: ({ row }) => {
const price = parseFloat(row.getValue("price"));
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
return {formatted}
;
},
},
{
accessorKey: "isPublished",
header: "Status",
cell: ({ row }) => {
const isPublished = row.getValue("isPublished") || false;
return (
{isPublished ? "Published" : "Draft"}
);
},
},
{
id: "actions",
cell: ({ row }) => (
Edit
),
},
];
================================================
FILE: components/courses/CourseCard.tsx
================================================
import { db } from "@/lib/db";
import { clerkClient } from "@clerk/nextjs/server";
import { Course } from "@prisma/client";
import { Gem } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
const CourseCard = async ({ course }: { course: Course }) => {
const instructor = await clerkClient.users.getUser(course.instructorId);
let level;
if (course.levelId) {
level = await db.level.findUnique({
where: {
id: course.levelId,
},
});
}
return (
{course.title}
{instructor && (
)}
{level && (
)}
$ {course.price}
);
};
export default CourseCard;
================================================
FILE: components/courses/CreateCourseForm.tsx
================================================
"use client";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import axios from "axios";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ComboBox } from "@/components/custom/ComboBox";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { Loader2 } from "lucide-react";
const formSchema = z.object({
title: z.string().min(2, {
message: "Title is required and minimum 2 characters",
}),
categoryId: z.string().min(1, {
message: "Category is required",
}),
subCategoryId: z.string().min(1, {
message: "Subcategory is required",
}),
});
interface CreateCourseFormProps {
categories: {
label: string; // name of category
value: string; // categoryId
subCategories: { label: string; value: string }[];
}[];
}
const CreateCourseForm = ({ categories }: CreateCourseFormProps) => {
const router = useRouter();
// 1. Define your form.
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
categoryId: "",
subCategoryId: "",
},
});
const { isValid, isSubmitting } = form.formState;
// 2. Define a submit handler.
const onSubmit = async (values: z.infer) => {
try {
const response = await axios.post("/api/courses", values);
router.push(`/instructor/courses/${response.data.id}/basic`);
toast.success("New Course Created");
} catch (err) {
console.log("Failed to create new course", err);
toast.error("Something went wrong!");
}
};
return (
Let give some basics for your course
It is ok if you cannot think of a good title or correct category now.
You can change them later.
);
};
export default CreateCourseForm;
================================================
FILE: components/courses/EditCourseForm.tsx
================================================
"use client";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Course } from "@prisma/client";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import RichEditor from "@/components/custom/RichEditor";
import { ComboBox } from "../custom/ComboBox";
import FileUpload from "../custom/FileUpload";
import Link from "next/link";
import axios from "axios";
import { usePathname, useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { Loader2, Trash } from "lucide-react";
import Delete from "../custom/Delete";
import PublishButton from "../custom/PublishButton";
const formSchema = z.object({
title: z.string().min(2, {
message: "Title is required and must be at least 2 characters long",
}),
subtitle: z.string().optional(),
description: z.string().optional(),
categoryId: z.string().min(1, {
message: "Category is required",
}),
subCategoryId: z.string().min(1, {
message: "Subcategory is required",
}),
levelId: z.string().optional(),
imageUrl: z.string().optional(),
price: z.coerce.number().optional(),
});
interface EditCourseFormProps {
course: Course;
categories: {
label: string; // name of category
value: string; // categoryId
subCategories: { label: string; value: string }[];
}[];
levels: { label: string; value: string }[];
isCompleted: boolean;
}
const EditCourseForm = ({
course,
categories,
levels,
isCompleted,
}: EditCourseFormProps) => {
const router = useRouter();
const pathname = usePathname();
// 1. Define your form.
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
title: course.title,
subtitle: course.subtitle || "",
description: course.description || "",
categoryId: course.categoryId,
subCategoryId: course.subCategoryId,
levelId: course.levelId || "",
imageUrl: course.imageUrl || "",
price: course.price || undefined,
},
});
const { isValid, isSubmitting } = form.formState;
// 2. Define a submit handler.
const onSubmit = async (values: z.infer) => {
try {
await axios.patch(`/api/courses/${course.id}`, values);
toast.success("Course Updated");
router.refresh();
} catch (err) {
console.log("Failed to update the course", err);
toast.error("Something went wrong!");
}
};
const routes = [
{
label: "Basic Information",
path: `/instructor/courses/${course.id}/basic`,
},
{ label: "Curriculum", path: `/instructor/courses/${course.id}/sections` },
];
return (
<>
{routes.map((route) => (
))}
>
);
};
export default EditCourseForm;
================================================
FILE: components/custom/AlertBanner.tsx
================================================
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Rocket, TriangleAlert } from "lucide-react";
interface AlertBannerProps {
isCompleted: boolean;
requiredFieldsCount: number;
missingFieldsCount: number;
}
const AlertBanner = ({
isCompleted,
requiredFieldsCount,
missingFieldsCount,
}: AlertBannerProps) => {
return (
{isCompleted ? (
) : (
)}
{missingFieldsCount} missing field(s) / {requiredFieldsCount} required
fields
{isCompleted
? "Great job! Ready to publish"
: "You can only publish when all the required fields are completed"}
);
};
export default AlertBanner;
================================================
FILE: components/custom/Categories.tsx
================================================
"use client"
import { Category } from "@prisma/client";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
interface CategoriesProps {
categories: Category[];
selectedCategory: string | null;
}
const Categories = ({ categories, selectedCategory }: CategoriesProps) => {
const router = useRouter();
const onClick = (categoryId: string | null) => {
router.push(categoryId ? `/categories/${categoryId}` : "/");
};
return (
{categories.map((category) => (
))}
);
};
export default Categories;
================================================
FILE: components/custom/ComboBox.tsx
================================================
"use client"
import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface ComboBoxProps {
options: { label: string, value: string }[]
value?: string
onChange: (value: string) => void
}
export function ComboBox({ options, value, onChange }: ComboBoxProps) {
const [open, setOpen] = React.useState(false)
return (
No option found.
{options.map((option) => (
{
onChange(option.value === value ? "" : option.value)
setOpen(false)
}}
>
{option.label}
))}
)
}
================================================
FILE: components/custom/DataTable.tsx
================================================
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"
import { useState } from "react";
interface DataTableProps {
columns: ColumnDef[];
data: TData[];
}
export function DataTable({
columns,
data,
}: DataTableProps) {
const [sorting, setSorting] = useState([])
const [columnFilters, setColumnFilters] = useState(
[]
)
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters
}
});
return (
);
}
================================================
FILE: components/custom/Delete.tsx
================================================
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import axios from "axios";
import { Loader2, Trash } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { Button } from "../ui/button";
interface DeleteProps {
item: string;
courseId: string;
sectionId?: string;
}
const Delete = ({ item, courseId, sectionId }: DeleteProps) => {
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const onDelete = async () => {
try {
setIsDeleting(true);
const url =
item === "course"
? `/api/courses/${courseId}`
: `/api/courses/${courseId}/sections/${sectionId}`;
await axios.delete(url);
setIsDeleting(false);
const pushedUrl =
item === "course"
? "/instructor/courses"
: `/instructor/courses/${courseId}/sections`;
router.push(pushedUrl);
router.refresh();
toast.success(`${item} deleted`);
} catch (err) {
toast.error(`Something went wrong!`);
console.log(`Failed to delete the ${item}`, err);
}
};
return (
Are you absolutely sure?
This action cannot be undone. This will permanently delete your {item}
Cancel
Delete
);
};
export default Delete;
================================================
FILE: components/custom/FileUpload.tsx
================================================
"use client";
import { ourFileRouter } from "@/app/api/uploadthing/core";
import { UploadDropzone } from "@/lib/uploadthing";
import Image from "next/image";
import toast from "react-hot-toast";
interface FileUploadProps {
value: string;
onChange: (url?: string) => void;
endpoint: keyof typeof ourFileRouter;
page: string;
}
const FileUpload = ({ value, onChange, endpoint, page }: FileUploadProps) => {
return (
{page === "Edit Course" && value !== "" && (
)}
{page === "Edit Section" && value !== "" && (
{value}
)}
{
onChange(res?.[0].url);
}}
onUploadError={(error: Error) => {
toast.error(error.message);
}}
className="w-[280px] h-[200px]"
/>
);
};
export default FileUpload;
================================================
FILE: components/custom/PublishButton.tsx
================================================
"use client";
import { Button } from "@/components/ui/button";
import axios from "axios";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
interface PublishButtonProps {
disabled: boolean;
courseId: string;
sectionId?: string;
isPublished: boolean;
page: string;
}
const PublishButton = ({
disabled,
courseId,
sectionId,
isPublished,
page,
}: PublishButtonProps) => {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const onClick = async () => {
let url = `/api/courses/${courseId}`;
if (page === "Section") {
url += `/sections/${sectionId}`;
}
try {
setIsLoading(true);
isPublished
? await axios.post(`${url}/unpublish`)
: await axios.post(`${url}/publish`);
toast.success(`${page} ${isPublished ? "unpublished" : "published"}`);
router.refresh();
} catch (err) {
toast.error("Something went wrong!");
console.log(
`Failed to ${isPublished ? "unpublish" : "publish"} ${page}`,
err
);
} finally {
setIsLoading(false);
}
};
return (
);
};
export default PublishButton;
================================================
FILE: components/custom/ReadText.tsx
================================================
"use client"
import dynamic from "next/dynamic";
import { useMemo } from "react";
import "react-quill/dist/quill.bubble.css";
const ReadText = ({ value }: { value: string }) => {
const ReactQuill = useMemo(
() => dynamic(() => import("react-quill"), { ssr: false }),
[]
);
return (
);
};
export default ReadText;
================================================
FILE: components/custom/RichEditor.tsx
================================================
"use client"
import "react-quill/dist/quill.snow.css";
import dynamic from "next/dynamic";
import { useMemo } from "react";
interface RichEditorProps {
placeholder: string;
onChange: (value: string) => void;
value?: string;
}
const RichEditor = ({ placeholder, onChange, value }: RichEditorProps) => {
const ReactQuill = useMemo(
() => dynamic(() => import("react-quill"), { ssr: false }),
[]
);
return (
);
};
export default RichEditor;
================================================
FILE: components/layout/CourseSideBar.tsx
================================================
import { db } from "@/lib/db";
import { Course, Section } from "@prisma/client";
import Link from "next/link";
import { Progress } from "../ui/progress";
interface CourseSideBarProps {
course: Course & { sections: Section[] };
studentId: string;
}
const CourseSideBar = async ({ course, studentId }: CourseSideBarProps) => {
const publishedSections = await db.section.findMany({
where: {
courseId: course.id,
isPublished: true,
},
orderBy: {
position: "asc",
},
});
const publishedSectionIds = publishedSections.map((section) => section.id);
const purchase = await db.purchase.findUnique({
where: {
customerId_courseId: {
customerId: studentId,
courseId: course.id,
},
},
});
const completedSections = await db.progress.count({
where:{
studentId,
sectionId: {
in: publishedSectionIds,
},
isCompleted: true,
}
});
const progressPercentage = (completedSections / publishedSectionIds.length) * 100;
return (
{course.title}
{purchase && (
{Math.round(progressPercentage)}% completed
)}
Overview
{publishedSections.map((section) => (
{section.title}
))}
);
};
export default CourseSideBar;
================================================
FILE: components/layout/SectionMenu.tsx
================================================
import { Course, Section } from "@prisma/client";
import React from "react";
import { Sheet, SheetContent, SheetTrigger } from "../ui/sheet";
import { Button } from "../ui/button";
import Link from "next/link";
interface SectionMenuProps {
course: Course & { sections: Section[] };
}
const SectionMenu = ({ course }: SectionMenuProps) => {
return (
Overview
{course.sections.map((section) => (
{section.title}
))}
);
};
export default SectionMenu;
================================================
FILE: components/layout/Sidebar.tsx
================================================
"use client";
import { BarChart4, MonitorPlay } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
const Sidebar = () => {
const pathname = usePathname();
const sidebarRoutes = [
{ icon: , label: "Courses", path: "/instructor/courses" },
{
icon: ,
label: "Performance",
path: "/instructor/performance",
},
];
return (
{sidebarRoutes.map((route) => (
{route.icon} {route.label}
))}
);
};
export default Sidebar;
================================================
FILE: components/layout/Topbar.tsx
================================================
"use client";
import { UserButton, useAuth } from "@clerk/nextjs";
import { Menu, Search } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
const Topbar = () => {
const { isSignedIn } = useAuth();
const router = useRouter();
const pathName = usePathname();
const topRoutes = [
{ label: "Instructor", path: "/instructor/courses" },
{ label: "Learning", path: "/learning" },
];
const sidebarRoutes = [
{ label: "Courses", path: "/instructor/courses" },
{
label: "Performance",
path: "/instructor/performance",
},
];
const [searchInput, setSearchInput] = useState("");
const handleSearch = () => {
if (searchInput.trim() !== "") {
router.push(`/search?query=${searchInput}`);
}
setSearchInput("");
};
return (
setSearchInput(e.target.value)}
/>
{topRoutes.map((route) => (
{route.label}
))}
{topRoutes.map((route) => (
{route.label}
))}
{pathName.startsWith("/instructor") && (
{sidebarRoutes.map((route) => (
{route.label}
))}
)}
{isSignedIn ? (
) : (
)}
);
};
export default Topbar;
================================================
FILE: components/performance/Chart.tsx
================================================
"use client"
import {
ResponsiveContainer,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
} from "recharts";
import { Card } from "@/components/ui/card";
const Chart = ({ data }: { data: { name: string; total: number }[] }) => {
return (
`$${value}`}
/>
);
};
export default Chart;
================================================
FILE: components/performance/DataCard.tsx
================================================
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatPrice } from "@/lib/formatPrice";
interface DataCardProps {
value: number;
label: string;
shouldFormat?: boolean;
}
const DataCard = ({ value, label, shouldFormat }: DataCardProps) => {
return (
{label}
{shouldFormat ? formatPrice(value) : value}
);
};
export default DataCard;
================================================
FILE: components/providers/ToasterProvider.tsx
================================================
"use client"
import { Toaster } from "react-hot-toast"
const ToasterProvider = () => {
return (
)
}
export default ToasterProvider
================================================
FILE: components/sections/CreateSectionForm.tsx
================================================
"use client";
import { Course, Section } from "@prisma/client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import toast from "react-hot-toast";
import axios from "axios";
import SectionList from "@/components/sections/SectionList";
import { Loader2 } from "lucide-react";
const formSchema = z.object({
title: z.string().min(2, {
message: "Title is required and must be at least 2 characters long",
}),
});
const CreateSectionForm = ({
course,
}: {
course: Course & { sections: Section[] };
}) => {
const pathname = usePathname();
const router = useRouter();
const routes = [
{
label: "Basic Information",
path: `/instructor/courses/${course.id}/basic`,
},
{ label: "Curriculum", path: `/instructor/courses/${course.id}/sections` },
];
// 1. Define your form.
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
},
});
const { isValid, isSubmitting } = form.formState;
// 2. Define a submit handler.
const onSubmit = async (values: z.infer) => {
try {
const response = await axios.post(
`/api/courses/${course.id}/sections`,
values
);
router.push(
`/instructor/courses/${course.id}/sections/${response.data.id}`
);
toast.success("New Section created!");
} catch (err) {
toast.error("Something went wrong!");
console.log("Failed to create a new section", err);
}
};
const onReorder = async (updateData: { id: string; position: number }[]) => {
try {
await axios.put(`/api/courses/${course.id}/sections/reorder`, {
list: updateData,
});
toast.success("Sections reordered successfully");
} catch (err) {
console.log("Failed to reorder sections", err);
toast.error("Something went wrong!");
}
};
return (
{routes.map((route) => (
))}
router.push(`/instructor/courses/${course.id}/sections/${id}`)
}
/>
Add New Section
);
};
export default CreateSectionForm;
================================================
FILE: components/sections/EditSectionForm.tsx
================================================
"use client";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { MuxData, Resource, Section } from "@prisma/client";
import Link from "next/link";
import axios from "axios";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { ArrowLeft, Loader2, Trash } from "lucide-react";
import MuxPlayer from "@mux/mux-player-react";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import RichEditor from "@/components/custom/RichEditor";
import FileUpload from "../custom/FileUpload";
import { Switch } from "@/components/ui/switch";
import ResourceForm from "@/components/sections/ResourceForm";
import Delete from "@/components/custom/Delete";
import PublishButton from "@/components/custom/PublishButton";
const formSchema = z.object({
title: z.string().min(2, {
message: "Title is required and must be at least 2 characters long",
}),
description: z.string().optional(),
videoUrl: z.string().optional(),
isFree: z.boolean().optional(),
});
interface EditSectionFormProps {
section: Section & { resources: Resource[]; muxData?: MuxData | null };
courseId: string;
isCompleted: boolean;
}
const EditSectionForm = ({
section,
courseId,
isCompleted,
}: EditSectionFormProps) => {
const router = useRouter();
// 1. Define your form.
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
title: section.title,
description: section.description || "",
videoUrl: section.videoUrl || "",
isFree: section.isFree,
},
});
const { isValid, isSubmitting } = form.formState;
// 2. Define a submit handler.
const onSubmit = async (values: z.infer) => {
try {
await axios.post(
`/api/courses/${courseId}/sections/${section.id}`,
values
);
toast.success("Section Updated");
router.refresh();
} catch (err) {
console.log("Failed to update the section", err);
toast.error("Something went wrong!");
}
};
return (
<>
Section Details
Complete this section with detailed information, good video and
resources to give your students the best learning experience
>
);
};
export default EditSectionForm;
================================================
FILE: components/sections/ProgressButton.tsx
================================================
"use client";
import axios from "axios";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { CheckCircle, Loader2 } from "lucide-react";
interface ProgressButtonProps {
courseId: string;
sectionId: string;
isCompleted: boolean;
}
const ProgressButton = ({
courseId,
sectionId,
isCompleted,
}: ProgressButtonProps) => {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const onClick = async () => {
try {
setIsLoading(true);
await axios.post(`/api/courses/${courseId}/sections/${sectionId}/progress`, {
isCompleted: !isCompleted,
});
toast.success("Progress updated!");
router.refresh();
} catch (err) {
console.log("Failed to update progress", err);
toast.error("Something went wrong!");
} finally {
setIsLoading(false);
}
};
return (
);
};
export default ProgressButton;
================================================
FILE: components/sections/ResourceForm.tsx
================================================
"use client";
import { Resource, Section } from "@prisma/client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import axios from "axios";
import { File, Loader2, PlusCircle, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import FileUpload from "@/components/custom/FileUpload";
const formSchema = z.object({
name: z.string().min(2, {
message: "Name is required and must be at least 2 characters long",
}),
fileUrl: z.string().min(1, {
message: "File is required",
}),
});
interface ResourceFormProps {
section: Section & { resources: Resource[] };
courseId: string;
}
const ResourceForm = ({ section, courseId }: ResourceFormProps) => {
const router = useRouter();
// 1. Define your form.
const form = useForm>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
fileUrl: "",
},
});
const { isValid, isSubmitting } = form.formState;
// 2. Define a submit handler.
const onSubmit = async (values: z.infer) => {
try {
await axios.post(
`/api/courses/${courseId}/sections/${section.id}/resources`,
values
);
toast.success("New Resource uploaded!");
form.reset();
router.refresh();
} catch (err) {
toast.error("Something went wrong!");
console.log("Failed to upload resource", err);
}
};
const onDelete = async (id: string) => {
try {
await axios.post(
`/api/courses/${courseId}/sections/${section.id}/resources/${id}`
);
toast.success("Resource deleted!");
router.refresh();
} catch (err) {
toast.error("Something went wrong!");
console.log("Failed to delete resource", err);
}
};
return (
<>
Add resources to this section to help students learn better.
{section.resources.map((resource) => (
{resource.name}
))}
>
);
};
export default ResourceForm;
================================================
FILE: components/sections/SectionList.tsx
================================================
import { Section } from "@prisma/client";
import {
DragDropContext,
Droppable,
Draggable,
DropResult,
} from "@hello-pangea/dnd";
import { useEffect, useState } from "react";
import { Grip, Pencil } from "lucide-react";
interface SectionListProps {
items: Section[];
onReorder: (updateData: { id: string; position: number }[]) => void;
onEdit: (id: string) => void;
}
const SectionList = ({ items, onReorder, onEdit }: SectionListProps) => {
const [isMounted, setIsMounted] = useState(false);
const [sections, setSections] = useState(items);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
setSections(items);
}, [items]);
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(sections);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
const startIndex = Math.min(result.source.index, result.destination.index);
const endIndex = Math.max(result.source.index, result.destination.index);
const updatedSections = items.slice(startIndex, endIndex + 1);
setSections(items);
const bulkUpdateData = updatedSections.map((section) => ({
id: section.id,
position: items.findIndex((item) => item.id === section.id),
}));
onReorder(bulkUpdateData);
};
if (!isMounted) return null;
return (
{(provided) => (
0 ? "my-10" : "mt-7"
} flex flex-col gap-5`}
>
{sections.map((section, index) => (
{(provided) => (
)}
))}
{provided.placeholder}
)}
);
};
export default SectionList;
================================================
FILE: components/sections/SectionsDetails.tsx
================================================
"use client";
import {
Course,
MuxData,
Progress,
Purchase,
Resource,
Section,
} from "@prisma/client";
import toast from "react-hot-toast";
import { useState } from "react";
import axios from "axios";
import { File, Loader2, Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import ReadText from "@/components/custom/ReadText";
import MuxPlayer from "@mux/mux-player-react";
import Link from "next/link";
import ProgressButton from "./ProgressButton";
import SectionMenu from "../layout/SectionMenu";
interface SectionsDetailsProps {
course: Course & { sections: Section[] };
section: Section;
purchase: Purchase | null;
muxData: MuxData | null;
resources: Resource[] | [];
progress: Progress | null;
}
const SectionsDetails = ({
course,
section,
purchase,
muxData,
resources,
progress,
}: SectionsDetailsProps) => {
const [isLoading, setIsLoading] = useState(false);
const isLocked = !purchase && !section.isFree;
const buyCourse = async () => {
try {
setIsLoading(true);
const response = await axios.post(`/api/courses/${course.id}/checkout`);
window.location.assign(response.data.url);
} catch (err) {
console.log("Failed to chechout course", err);
toast.error("Something went wrong!");
} finally {
setIsLoading(false);
}
};
return (
{section.title}
{!purchase ? (
) : (
// !! converts falsy values to boolean false
)}
{isLocked ? (
Video for this section is locked!. Please buy the course to access
) : (
)}
Resources
{resources.map((resource) => (
{resource.name}
))}
);
};
export default SectionsDetails;
================================================
FILE: components/ui/alert-dialog.tsx
================================================
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes) => (
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes) => (
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
================================================
FILE: components/ui/alert.tsx
================================================
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-2 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
complete: "bg-[#C5DDD6] text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes & VariantProps
>(({ className, variant, ...props }, ref) => (
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
================================================
FILE: components/ui/badge.tsx
================================================
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full 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",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes,
VariantProps {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
)
}
export { Badge, badgeVariants }
================================================
FILE: components/ui/button.tsx
================================================
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-[#FDAB04] text-black-foreground hover:bg-[#FDAB04]/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-[#FFF8EB] hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
complete: "bg-[#C5DDD6] text-black-foreground hover:bg-[#C5DDD6]/90"
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
asChild?: boolean
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
================================================
FILE: components/ui/card.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
================================================
FILE: components/ui/command.tsx
================================================
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
)
}
const CommandInput = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>((props, ref) => (
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes) => {
return (
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
================================================
FILE: components/ui/dialog.tsx
================================================
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, children, ...props }, ref) => (
{children}
Close
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes) => (
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes) => (
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
================================================
FILE: components/ui/form.tsx
================================================
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath = FieldPath
> = {
name: TName
}
const FormFieldContext = React.createContext(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath = FieldPath
>({
...props
}: ControllerProps) => {
return (
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within ")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
{body}
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
================================================
FILE: components/ui/input.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes {}
const Input = React.forwardRef(
({ className, type, ...props }, ref) => {
return (
)
}
)
Input.displayName = "Input"
export { Input }
================================================
FILE: components/ui/label.tsx
================================================
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef &
VariantProps
>(({ className, ...props }, ref) => (
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
================================================
FILE: components/ui/popover.tsx
================================================
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
================================================
FILE: components/ui/progress.tsx
================================================
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, value, ...props }, ref) => (
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
================================================
FILE: components/ui/sheet.tsx
================================================
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef,
VariantProps {}
const SheetContent = React.forwardRef<
React.ElementRef,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
{children}
Close
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes) => (
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes) => (
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
================================================
FILE: components/ui/switch.tsx
================================================
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
================================================
FILE: components/ui/table.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes
>(({ className, ...props }, ref) => (
|
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes
>(({ className, ...props }, ref) => (
|
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
================================================
FILE: components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
================================================
FILE: lib/db.ts
================================================
import { PrismaClient } from "@prisma/client";
declare global {
var prisma: PrismaClient | undefined
}
export const db = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== "production") globalThis.prisma = db
================================================
FILE: lib/formatPrice.ts
================================================
export const formatPrice = (price: number) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "CAD",
}).format(price)
}
================================================
FILE: lib/stripe.ts
================================================
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
apiVersion: "2024-04-10",
typescript: true,
});
================================================
FILE: lib/uploadthing.ts
================================================
import {
generateUploadButton,
generateUploadDropzone,
} from "@uploadthing/react";
import type { OurFileRouter } from "@/app/api/uploadthing/core";
export const UploadButton = generateUploadButton();
export const UploadDropzone = generateUploadDropzone();
================================================
FILE: lib/utils.ts
================================================
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
================================================
FILE: middleware.ts
================================================
import { clerkMiddleware } from "@clerk/nextjs/server";
export default clerkMiddleware();
export const config = {
matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};
================================================
FILE: next.config.mjs
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{ hostname: 'utfs.io'},
{ hostname: 'img.clerk.com'}
]
}
};
export default nextConfig;
================================================
FILE: package.json
================================================
{
"name": "academy",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate"
},
"dependencies": {
"@clerk/nextjs": "^5.1.3",
"@hello-pangea/dnd": "^16.6.0",
"@hookform/resolvers": "^3.4.2",
"@mux/mux-node": "^8.7.1",
"@mux/mux-player-react": "^2.7.0",
"@prisma/client": "^5.14.0",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@tanstack/react-table": "^8.17.3",
"@uploadthing/react": "^6.6.0",
"axios": "^1.7.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"cmdk": "^0.2.1",
"lucide-react": "^0.379.0",
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
"react-hook-form": "^7.51.5",
"react-hook-toast": "^0.0.3",
"react-hot-toast": "^2.4.1",
"react-quill": "^2.0.0",
"recharts": "^2.12.7",
"stripe": "^15.9.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"uploadthing": "^6.12.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"postcss": "^8",
"prisma": "^5.14.0",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
================================================
FILE: postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
================================================
FILE: prisma/schema.prisma
================================================
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}
model Course {
id String @id @default(uuid())
instructorId String
title String @db.Text
subtitle String? @db.Text
description String? @db.Text
imageUrl String? @db.Text
price Float?
isPublished Boolean @default(false)
categoryId String
category Category @relation(fields: [categoryId], references: [id])
subCategoryId String
subCategory SubCategory @relation(fields: [subCategoryId], references: [id])
levelId String?
level Level? @relation(fields: [levelId], references: [id])
sections Section[]
purchases Purchase[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
@@index([categoryId])
@@index([subCategoryId])
@@index([levelId])
}
model Category {
id String @id @default(uuid())
name String @unique
subCategories SubCategory[]
courses Course[]
}
model SubCategory {
id String @id @default(uuid())
name String
categoryId String
category Category @relation(fields: [categoryId], references: [id])
courses Course[]
@@index([categoryId])
}
model Level {
id String @id @default(uuid())
name String @unique
courses Course[]
}
model Section {
id String @id @default(uuid())
title String
description String? @db.Text
videoUrl String? @db.Text
position Int
isPublished Boolean @default(false)
isFree Boolean @default(false)
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
muxData MuxData?
resources Resource[]
progress Progress[]
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
@@index([courseId])
}
model MuxData {
id String @id @default(uuid())
assetId String
playbackId String?
sectionId String @unique
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
@@index([sectionId])
}
model Resource {
id String @id @default(uuid())
name String
fileUrl String
sectionId String
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
@@index([sectionId])
}
model Progress {
id String @id @default(uuid())
studentId String
sectionId String
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
isCompleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
@@index([sectionId])
@@unique([studentId, sectionId])
}
model Purchase {
id String @id @default(uuid())
customerId String
courseId String
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
@@index([courseId])
@@unique([customerId, courseId])
}
model StripeCustomer {
id String @id @default(uuid())
customerId String @unique
stripeCustomerId String @unique
createdAt DateTime @default(now())
updatedAt DateTime @default(now())
}
================================================
FILE: scripts/seed.ts
================================================
const { PrismaClient } = require("@prisma/client");
const database = new PrismaClient();
async function main() {
try {
const categories = [
{
name: "IT & Software",
subCategories: {
create: [
{ name: "Web Development" },
{ name: "Data Science" },
{ name: "Cybersecurity" },
{ name: "Others" },
],
},
},
{
name: "Business",
subCategories: {
create: [
{ name: "E-Commerce" },
{ name: "Marketing" },
{ name: "Finance" },
{ name: "Others" },
],
},
},
{
name: "Design",
subCategories: {
create: [
{ name: "Graphic Design" },
{ name: "3D & Animation" },
{ name: "Interior Design" },
{ name: "Others" },
],
},
},
{
name: "Health",
subCategories: {
create: [
{ name: "Fitness" },
{ name: "Yoga" },
{ name: "Nutrition" },
{ name: "Others" },
],
},
},
];
// Sequentially create each category with its subcategories
for (const category of categories) {
await database.category.create({
data: {
name: category.name,
subCategories: category.subCategories,
},
include: {
subCategories: true,
},
});
}
await database.level.createMany({
data: [
{ name: "Beginner" },
{ name: "Intermediate" },
{ name: "Expert" },
{ name: "All levels" },
],
});
console.log("Seeding successfully");
} catch (error) {
console.log("Seeding failed", error);
} finally {
await database.$disconnect();
}
}
main();
================================================
FILE: tailwind.config.ts
================================================
import type { Config } from "tailwindcss"
const config = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config
export default config
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}