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 (
<div className="h-full flex items-center justify-center">{children}</div>
)
}
export default AuthLayout
================================================
FILE: app/(auth)/sign-in/[[...sign-in]]/page.tsx
================================================
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return <SignIn />;
}
================================================
FILE: app/(auth)/sign-up/[[...sign-up]]/page.tsx
================================================
import { SignUp } from "@clerk/nextjs";
export default function Page() {
return <SignUp />;
}
================================================
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 (
<div className="h-full flex flex-col">
<Topbar />
<div className="flex-1 flex">
<CourseSideBar course={course} studentId={userId} />
<div className="flex-1">{children}</div>
</div>
</div>
);
};
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 (
<div className="px-6 py-4 flex flex-col gap-5 text-sm">
<div className="flex justify-between">
<h1 className="text-2xl font-bold">{course.title}</h1>
<SectionMenu course={course} />
</div>
<p className="font-medium">{course.subtitle}</p>
<div className="flex gap-2 items-center">
<Image
src={
instructor.imageUrl
? instructor.imageUrl
: "/avatar_placeholder.jpg"
}
alt={instructor.fullName ? instructor.fullName : "Instructor photo"}
width={30}
height={30}
className="rounded-full"
/>
<p className="font-bold">Instructor:</p>
<p>{instructor.fullName}</p>
</div>
<div className="flex gap-2">
<p className="font-bold">Price:</p>
<p>${course.price}</p>
</div>
<div className="flex gap-2">
<p className="font-bold">Level:</p>
<p>{level?.name}</p>
</div>
<div className="flex flex-col gap-2">
<p className="font-bold">Description:</p>
<ReadText value={course.description!} />
</div>
</div>
);
};
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 (
<SectionsDetails
course={course}
section={section}
purchase={purchase}
muxData={muxData}
resources={resources}
progress={progress}
/>
);
};
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 (
<div className="md:mt-5 md:px-10 xl:px-16 pb-16">
<Categories categories={categories} selectedCategory={params.categoryId} />
<div className="flex flex-wrap gap-7 justify-center">
{courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
</div>
);
};
export default CoursesByCategory;
================================================
FILE: app/(home)/layout.tsx
================================================
import Topbar from "@/components/layout/Topbar"
const HomeLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<Topbar />
{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 (
<div className="px-4 py-6 md:mt-5 md:px-10 xl:px-16">
<h1 className="text-2xl font-bold">
Your courses
</h1>
<div className="flex flex-wrap gap-7 mt-7">
{purchasedCourses.map((purchase) => (
<CourseCard key={purchase.course.id} course={purchase.course} />
))}
</div>
</div>
)
}
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 (
<div className="md:mt-5 md:px-10 xl:px-16 pb-16">
<Categories categories={categories} selectedCategory={null} />
<div className="flex flex-wrap gap-7 justify-center">
{courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
</div>
);
}
================================================
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 (
<div className="px-4 py-6 md:px-10 xl:px-16">
<p className="text-lg md:text-2xl font-semibold mb-10">Recommended courses for {queryText}</p>
<div className="flex gap-4 flex-wrap">
{courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
</div>
)
}
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 (
<div className="px-10">
<AlertBanner
isCompleted={isCompleted}
missingFieldsCount={missingFieldsCount}
requiredFieldsCount={requiredFieldsCount}
/>
<EditCourseForm
course={course}
categories={categories.map((category) => ({
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}
/>
</div>
);
};
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 (
<div className="px-10">
<AlertBanner
isCompleted={isCompleted}
requiredFieldsCount={requiredFieldsCount}
missingFieldsCount={missingFieldsCount}
/>
<EditSectionForm
section={section}
courseId={params.courseId}
isCompleted={isCompleted}
/>
</div>
);
};
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 (
<CreateSectionForm course={course} />
);
}
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 (
<div className="px-6 py-4">
<Link href="/instructor/create-course">
<Button>Create New Course</Button>
</Link>
<div className="mt-5">
<DataTable columns={columns} data={courses} />
</div>
</div>
);
};
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 (
<div>
<CreateCourseForm categories={categories.map((category) => ({
label: category.name,
value: category.id,
subCategories: category.subCategories.map((subcategory) => ({
label: subcategory.name,
value: subcategory.id
}))
}))} />
</div>
)
}
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 (
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<DataCard value={totalRevenue} label="Total Revenue" shouldFormat />
<DataCard value={totalSales} label="Total Sales" />
<Chart data={data} />
</div>
</div>
)
}
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 (
<div className="h-full flex flex-col">
<Topbar />
<div className="flex-1 flex">
<Sidebar />
<div className="flex-1">{children}</div>
</div>
</div>
);
};
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<Course[]> => {
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 (
<ClerkProvider>
<html lang="en">
<body className={inter.className}>
<ToasterProvider />
{children}
</body>
</html>
</ClerkProvider>
);
}
================================================
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<Course>[] = [
{
accessorKey: "title", // course.title
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Title
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "price",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Price
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const price = parseFloat(row.getValue("price"));
const formatted = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
return <div>{formatted}</div>;
},
},
{
accessorKey: "isPublished",
header: "Status",
cell: ({ row }) => {
const isPublished = row.getValue("isPublished") || false;
return (
<Badge
className={`${
isPublished && "bg-[#FDAB04] text-black hover:bg-[#FDAB04]"
}`}
>
{isPublished ? "Published" : "Draft"}
</Badge>
);
},
},
{
id: "actions",
cell: ({ row }) => (
<Link
href={`/instructor/courses/${row.original.id}/basic`}
className="flex gap-2 items-center hover:text-[#FDAB04]"
>
<Pencil className="h-4 w-4" /> Edit
</Link>
),
},
];
================================================
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 (
<Link
href={`/courses/${course.id}/overview`}
className="border rounded-lg cursor-pointer"
>
<Image
src={course.imageUrl ? course.imageUrl : "/image_placeholder.webp"}
alt={course.title}
width={500}
height={300}
className="rounded-t-xl w-[320px] h-[180px] object-cover"
/>
<div className="px-4 py-3 flex flex-col gap-2">
<h2 className="text-lg font-bold hover:[#FDAB04]">{course.title}</h2>
<div className="flex justify-between text-sm font-medium">
{instructor && (
<div className="flex gap-2 items-center">
<Image
src={
instructor.imageUrl
? instructor.imageUrl
: "/avatar_placeholder.jpg"
}
alt={
instructor.fullName ? instructor.fullName : "Instructor photo"
}
width={30}
height={30}
className="rounded-full"
/>
<p>{instructor.fullName}</p>
</div>
)}
{level && (
<div className="flex gap-2">
<Gem size={20} />
<p>{level.name}</p>
</div>
)}
</div>
<p className="text-sm font-bold">$ {course.price}</p>
</div>
</Link>
);
};
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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
categoryId: "",
subCategoryId: "",
},
});
const { isValid, isSubmitting } = form.formState;
// 2. Define a submit handler.
const onSubmit = async (values: z.infer<typeof formSchema>) => {
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 (
<div className="p-10">
<h1 className="text-xl font-bold">
Let give some basics for your course
</h1>
<p className="text-sm mt-3">
It is ok if you cannot think of a good title or correct category now.
You can change them later.
</p>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 mt-10"
>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input
placeholder="Ex: Web Development for Beginners"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Category</FormLabel>
<FormControl>
<ComboBox options={categories} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subCategoryId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Subcategory</FormLabel>
<FormControl>
<ComboBox
options={
categories.find(
(category) =>
category.value === form.watch("categoryId")
)?.subCategories || []
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Create"
)}
</Button>
</form>
</Form>
</div>
);
};
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<z.infer<typeof formSchema>>({
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<typeof formSchema>) => {
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 (
<>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between mb-7">
<div className="flex gap-5">
{routes.map((route) => (
<Link key={route.path} href={route.path}>
<Button variant={pathname === route.path ? "default" : "outline"}>
{route.label}
</Button>
</Link>
))}
</div>
<div className="flex gap-5 items-start">
<PublishButton
disabled={!isCompleted}
courseId={course.id}
isPublished={course.isPublished}
page="Course"
/>
<Delete item="course" courseId={course.id} />
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
Title <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Ex: Web Development for Beginners"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subtitle"
render={({ field }) => (
<FormItem>
<FormLabel>Subtitle</FormLabel>
<FormControl>
<Input
placeholder="Ex: Become a Full-stack Developer with just ONE course. HTML, CSS, Javascript, Node, React, MongoDB and more!"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
Description <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<RichEditor
placeholder="What is this course about?"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-wrap gap-10">
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Category <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<ComboBox options={categories} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subCategoryId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Subcategory <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<ComboBox
options={
categories.find(
(category) =>
category.value === form.watch("categoryId")
)?.subCategories || []
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="levelId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Level <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<ComboBox options={levels} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="imageUrl"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Couse Banner <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<FileUpload
value={field.value || ""}
onChange={(url) => field.onChange(url)}
endpoint="courseBanner"
page="Edit Course"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>
Price <span className="text-red-500">*</span> (USD)
</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="29.99"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-5">
<Link href="/instructor/courses">
<Button variant="outline" type="button">
Cancel
</Button>
</Link>
<Button type="submit" disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Save"
)}
</Button>
</div>
</form>
</Form>
</>
);
};
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 (
<Alert
className="my-4"
variant={`${isCompleted ? "complete" : "destructive"}`}
>
{isCompleted ? (
<Rocket className="h-4 w-4" />
) : (
<TriangleAlert className="h-4 w-4" />
)}
<AlertTitle className="text-xs font-medium">
{missingFieldsCount} missing field(s) / {requiredFieldsCount} required
fields
</AlertTitle>
<AlertDescription className="text-xs">
{isCompleted
? "Great job! Ready to publish"
: "You can only publish when all the required fields are completed"}
</AlertDescription>
</Alert>
);
};
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 (
<div className="flex flex-wrap px-4 gap-7 justify-center my-10">
<Button
variant={selectedCategory === null ? "default" : "outline"}
onClick={() => onClick(null)}
>
All Categories
</Button>
{categories.map((category) => (
<Button
key={category.id}
variant={selectedCategory === category.id ? "default" : "outline"}
onClick={() => onClick(category.id)}
>
{category.name}
</Button>
))}
</div>
);
};
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{value
? options.find((option) => option.value === value)?.label
: "Select option..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search option..." />
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
onChange(option.value === value ? "" : option.value)
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)
}
================================================
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<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(
[]
)
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters
}
});
return (
<div>
<div className="flex items-center py-4">
<Input
placeholder="Filter courses by title..."
value={(table.getColumn("title")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("title")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody className="text-sm font-medium">
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
);
}
================================================
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 (
<AlertDialog>
<AlertDialogTrigger>
<Button>
{isDeleting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash className="h-4 w-4" />
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-red-500">
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your {item}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction className="bg-[#FDAB04]" onClick={onDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
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 (
<div className="flex flex-col gap-2">
{page === "Edit Course" && value !== "" && (
<Image
src={value}
alt="image"
width={500}
height={500}
className="w-[280px] h-[200px] object-cover rounded-xl"
/>
)}
{page === "Edit Section" && value !== "" && (
<p className="text-sm font-medium">{value}</p>
)}
<UploadDropzone
endpoint={endpoint}
onClientUploadComplete={(res) => {
onChange(res?.[0].url);
}}
onUploadError={(error: Error) => {
toast.error(error.message);
}}
className="w-[280px] h-[200px]"
/>
</div>
);
};
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 (
<Button
variant="outline"
disabled={disabled || isLoading}
onClick={onClick}
>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : isPublished ? "Unpublish" : "Publish"}
</Button>
);
};
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 (
<ReactQuill
theme="bubble"
value={value}
readOnly
/>
);
};
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 (
<ReactQuill
theme="snow"
placeholder={placeholder}
value={value}
onChange={onChange}
/>
);
};
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 (
<div className="hidden md:flex flex-col w-64 border-r shadow-md px-3 my-4 text-sm font-medium">
<h1 className="text-lg font-bold text-center mb-4">{course.title}</h1>
{purchase && (
<div>
<Progress value={progressPercentage} className="h-2" />
<p className="text-xs">{Math.round(progressPercentage)}% completed</p>
</div>
)}
<Link
href={`/courses/${course.id}/overview`}
className={`p-3 rounded-lg hover:bg-[#FFF8EB] mt-4`}
>
Overview
</Link>
{publishedSections.map((section) => (
<Link
key={section.id}
href={`/courses/${course.id}/sections/${section.id}`}
className="p-3 rounded-lg hover:bg-[#FFF8EB] mt-4"
>
{section.title}
</Link>
))}
</div>
);
};
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 (
<div className="z-60 md:hidden">
<Sheet>
<SheetTrigger>
<Button>Sections</Button>
</SheetTrigger>
<SheetContent className="flex flex-col">
<Link
href={`/courses/${course.id}/overview`}
className={`p-3 rounded-lg hover:bg-[#FFF8EB] mt-4`}
>
Overview
</Link>
{course.sections.map((section) => (
<Link
key={section.id}
href={`/courses/${course.id}/sections/${section.id}`}
className="p-3 rounded-lg hover:bg-[#FFF8EB] mt-4"
>
{section.title}
</Link>
))}
</SheetContent>
</Sheet>
</div>
);
};
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: <MonitorPlay />, label: "Courses", path: "/instructor/courses" },
{
icon: <BarChart4 />,
label: "Performance",
path: "/instructor/performance",
},
];
return (
<div className="max-sm:hidden flex flex-col w-64 border-r shadow-md px-3 my-4 gap-4 text-sm font-medium">
{sidebarRoutes.map((route) => (
<Link
href={route.path}
key={route.path}
className={`flex items-center gap-4 p-3 rounded-lg hover:bg-[#FFF8EB]
${pathname.startsWith(route.path) && "bg-[#FDAB04] hover:bg-[#FDAB04]/80"}
`}
>
{route.icon} {route.label}
</Link>
))}
</div>
);
};
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 (
<div className="flex justify-between items-center p-4">
<Link href="/">
<Image src="/logo.png" height={100} width={200} alt="logo" />
</Link>
<div className="max-md:hidden w-[400px] rounded-full flex">
<input
className="flex-grow bg-[#FFF8EB] rounded-l-full border-none outline-none text-sm pl-4 py-3"
placeholder="Search for courses"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
<button
className="bg-[#FDAB04] rounded-r-full border-none outline-none cursor-pointer px-4 py-3 hover:bg-[#FDAB04]/80"
disabled={searchInput.trim() === ""}
onClick={handleSearch}
>
<Search className="h-4 w-4" />
</button>
</div>
<div className="flex gap-6 items-center">
<div className="max-sm:hidden flex gap-6">
{topRoutes.map((route) => (
<Link
href={route.path}
key={route.path}
className="text-sm font-medium hover:text-[#FDAB04]"
>
{route.label}
</Link>
))}
</div>
<div className="z-20 sm:hidden">
<Sheet>
<SheetTrigger>
<Menu className="w-5 h-5" />
</SheetTrigger>
<SheetContent className="flex flex-col gap-4">
<div className="flex flex-col gap-4">
{topRoutes.map((route) => (
<Link
href={route.path}
key={route.path}
className="text-sm font-medium hover:text-[#FDAB04]"
>
{route.label}
</Link>
))}
</div>
{pathName.startsWith("/instructor") && (
<div className="flex flex-col gap-4">
{sidebarRoutes.map((route) => (
<Link
href={route.path}
key={route.path}
className="text-sm font-medium hover:text-[#FDAB04]"
>
{route.label}
</Link>
))}
</div>
)}
</SheetContent>
</Sheet>
</div>
{isSignedIn ? (
<UserButton afterSignOutUrl="/sign-in" />
) : (
<Link href="/sign-in">
<Button>Sign In</Button>
</Link>
)}
</div>
</div>
);
};
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 (
<Card>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data}>
<XAxis
dataKey="name"
stroke="888888"
fontSize={12}
axisLine={false}
tickLine={false}
/>
<YAxis
stroke="888888"
fontSize={12}
axisLine={false}
tickLine={false}
tickFormatter={(value) => `$${value}`}
/>
<Tooltip />
<Bar dataKey="total" fill="#8884d8" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</Card>
);
};
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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-lg font-bold">
{shouldFormat ? formatPrice(value) : value}
</div>
</CardContent>
</Card>
);
};
export default DataCard;
================================================
FILE: components/providers/ToasterProvider.tsx
================================================
"use client"
import { Toaster } from "react-hot-toast"
const ToasterProvider = () => {
return (
<Toaster />
)
}
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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
},
});
const { isValid, isSubmitting } = form.formState;
// 2. Define a submit handler.
const onSubmit = async (values: z.infer<typeof formSchema>) => {
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 (
<div className="px-10 py-6">
<div className="flex gap-5">
{routes.map((route) => (
<Link key={route.path} href={route.path}>
<Button variant={pathname === route.path ? "default" : "outline"}>
{route.label}
</Button>
</Link>
))}
</div>
<SectionList
items={course.sections || []}
onReorder={onReorder}
onEdit={(id) =>
router.push(`/instructor/courses/${course.id}/sections/${id}`)
}
/>
<h1 className="text-xl font-bold mt-5">Add New Section</h1>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 mt-5">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Ex: Introduction" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-5">
<Link href={`/instructor/courses/${course.id}/basic`}>
<Button variant="outline" type="button">
Cancel
</Button>
</Link>
<Button type="submit" disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Create"
)}
</Button>
</div>
</form>
</Form>
</div>
);
};
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<z.infer<typeof formSchema>>({
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<typeof formSchema>) => {
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 (
<>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between mb-7">
<Link href={`/instructor/courses/${courseId}/sections`}>
<Button variant="outline" className="text-sm font-medium">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to curriculum
</Button>
</Link>
<div className="flex gap-5 items-start">
<PublishButton
disabled={!isCompleted}
courseId={courseId}
sectionId={section.id}
isPublished={section.isPublished}
page="Section"
/>
<Delete item="section" courseId={courseId} sectionId={section.id} />
</div>
</div>
<h1 className="text-xl font-bold">Section Details</h1>
<p className="text-sm font-medium mt-2">
Complete this section with detailed information, good video and
resources to give your students the best learning experience
</p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8 mt-5">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
Title <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Ex: Introduction to Web Development"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
Description <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<RichEditor
placeholder="What is this section about?"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{section.videoUrl && (
<div className="my-5">
<MuxPlayer
playbackId={section.muxData?.playbackId || ""}
className="md:max-w-[600px]"
/>
</div>
)}
<FormField
control={form.control}
name="videoUrl"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Video <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<FileUpload
value={field.value || ""}
onChange={(url) => field.onChange(url)}
endpoint="sectionVideo"
page="Edit Section"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isFree"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Accessibility</FormLabel>
<FormDescription>
Everyone can access this section for FREE
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-5">
<Link href={`/instructor/courses/${courseId}/sections`}>
<Button variant="outline" type="button">
Cancel
</Button>
</Link>
<Button type="submit" disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Save"
)}
</Button>
</div>
</form>
</Form>
<ResourceForm section={section} courseId={courseId} />
</>
);
};
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 (
<Button variant={isCompleted ? "complete" : "default"} onClick={onClick}>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isCompleted ? (
<div className="flex items-center">
<CheckCircle className="h-4 w-4 mr-2" />
<span>Completed</span>
</div>
) : (
"Mark as complete"
)}
</Button>
);
};
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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
fileUrl: "",
},
});
const { isValid, isSubmitting } = form.formState;
// 2. Define a submit handler.
const onSubmit = async (values: z.infer<typeof formSchema>) => {
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 (
<>
<div className="flex gap-2 items-center text-xl font-bold mt-12">
<PlusCircle />
Add Resources (optional)
</div>
<p className="text-sm font-medium mt-2">
Add resources to this section to help students learn better.
</p>
<div className="mt-5 flex flex-col gap-5">
{section.resources.map((resource) => (
<div key={resource.id} className="flex justify-between bg-[#FFF8EB] rounded-lg text-sm font-medium p-3">
<div className="flex items-center">
<File className="h-4 w-4 mr-4" />
{resource.name}
</div>
<button
className="text-[#FDAB04]"
disabled={isSubmitting}
onClick={() => onDelete(resource.id)}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<X className="h-4 w-4" />
)}
</button>
</div>
))}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 my-5"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>File Name</FormLabel>
<FormControl>
<Input placeholder="Ex: Textbook" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fileUrl"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Upload File</FormLabel>
<FormControl>
<FileUpload
value={field.value || ""}
onChange={(url) => field.onChange(url)}
endpoint="sectionResource"
page="Edit Section"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Upload"
)}
</Button>
</form>
</Form>
</div>
</>
);
};
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 (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="sections">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className={`${
sections.length > 0 ? "my-10" : "mt-7"
} flex flex-col gap-5`}
>
{sections.map((section, index) => (
<Draggable
key={section.id}
draggableId={section.id}
index={index}
>
{(provided) => (
<div
{...provided.draggableProps}
ref={provided.innerRef}
className="flex items-center bg-[#FFF8EB] rounded-lg text-sm font-medium p-3"
>
<div {...provided.dragHandleProps}>
<Grip className="h-4 w-4 cursor-pointer mr-4 hover:text-[#FDAB04]" />
</div>
{section.title}
<div className="ml-auto">
<Pencil
className="h-4 w-4 cursor-pointer hover:text-[#FDAB04]"
onClick={() => onEdit(section.id)}
/>
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
);
};
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 (
<div className="px-6 py-4 flex flex-col gap-5">
<div className="flex flex-col md:flex-row md:justify-between md:items-center">
<h1 className="text-2xl font-bold max-md:mb-4">{section.title}</h1>
<div className="flex gap-4">
<SectionMenu course={course} />
{!purchase ? (
<Button onClick={buyCourse}>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<p>Buy this course</p>
)}
</Button>
) : (
<ProgressButton
courseId={course.id}
sectionId={section.id}
isCompleted={!!progress?.isCompleted}
/> // !! converts falsy values to boolean false
)}
</div>
</div>
<ReadText value={section.description!} />
{isLocked ? (
<div className="px-10 flex flex-col gap-5 items-center bg-[#FFF8EB]">
<Lock className="h-8 w-8" />
<p className="text-sm font-bold">
Video for this section is locked!. Please buy the course to access
</p>
</div>
) : (
<MuxPlayer
playbackId={muxData?.playbackId || ""}
className="md:max-w-[600px]"
/>
)}
<div>
<h2 className="text-xl font-bold mb-5">Resources</h2>
{resources.map((resource) => (
<Link
key={resource.id}
href={resource.fileUrl}
target="_blank"
className="flex items-center bg-[#FFF8EB] rounded-lg text-sm font-medium p-3"
>
<File className="h-4 w-4 mr-4" />
{resource.name}
</Link>
))}
</div>
</div>
);
};
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<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
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<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
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<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
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<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
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 <FormField>")
}
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<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
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<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
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<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
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<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
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<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-[#FDAB04] transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
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<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={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<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[#FDAB04] data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
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<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
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<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();
================================================
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"]
}
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
SYMBOL INDEX (36 symbols across 32 files)
FILE: app/(auth)/sign-in/[[...sign-in]]/page.tsx
function Page (line 3) | function Page() {
FILE: app/(auth)/sign-up/[[...sign-up]]/page.tsx
function Page (line 3) | function Page() {
FILE: app/(home)/page.tsx
function Home (line 6) | async function Home() {
FILE: app/actions/getPerformance.ts
type PurchaseWithCourse (line 4) | type PurchaseWithCourse = Purchase & { course: Course };
FILE: app/api/uploadthing/core.ts
type OurFileRouter (line 26) | type OurFileRouter = typeof ourFileRouter;
FILE: app/layout.tsx
function RootLayout (line 15) | function RootLayout({
FILE: components/courses/CreateCourseForm.tsx
type CreateCourseFormProps (line 36) | interface CreateCourseFormProps {
FILE: components/courses/EditCourseForm.tsx
type EditCourseFormProps (line 46) | interface EditCourseFormProps {
FILE: components/custom/AlertBanner.tsx
type AlertBannerProps (line 4) | interface AlertBannerProps {
FILE: components/custom/Categories.tsx
type CategoriesProps (line 7) | interface CategoriesProps {
FILE: components/custom/ComboBox.tsx
type ComboBoxProps (line 21) | interface ComboBoxProps {
function ComboBox (line 27) | function ComboBox({ options, value, onChange }: ComboBoxProps) {
FILE: components/custom/DataTable.tsx
type DataTableProps (line 29) | interface DataTableProps<TData, TValue> {
function DataTable (line 34) | function DataTable<TData, TValue>({
FILE: components/custom/Delete.tsx
type DeleteProps (line 19) | interface DeleteProps {
FILE: components/custom/FileUpload.tsx
type FileUploadProps (line 8) | interface FileUploadProps {
FILE: components/custom/PublishButton.tsx
type PublishButtonProps (line 10) | interface PublishButtonProps {
FILE: components/custom/RichEditor.tsx
type RichEditorProps (line 7) | interface RichEditorProps {
FILE: components/layout/CourseSideBar.tsx
type CourseSideBarProps (line 6) | interface CourseSideBarProps {
FILE: components/layout/SectionMenu.tsx
type SectionMenuProps (line 7) | interface SectionMenuProps {
FILE: components/performance/DataCard.tsx
type DataCardProps (line 4) | interface DataCardProps {
FILE: components/sections/EditSectionForm.tsx
type EditSectionFormProps (line 41) | interface EditSectionFormProps {
FILE: components/sections/ProgressButton.tsx
type ProgressButtonProps (line 11) | interface ProgressButtonProps {
FILE: components/sections/ResourceForm.tsx
type ResourceFormProps (line 34) | interface ResourceFormProps {
FILE: components/sections/SectionList.tsx
type SectionListProps (line 11) | interface SectionListProps {
FILE: components/sections/SectionsDetails.tsx
type SectionsDetailsProps (line 23) | interface SectionsDetailsProps {
FILE: components/ui/badge.tsx
type BadgeProps (line 26) | interface BadgeProps
function Badge (line 30) | function Badge({ className, variant, ...props }: BadgeProps) {
FILE: components/ui/button.tsx
type ButtonProps (line 37) | interface ButtonProps
FILE: components/ui/command.tsx
type CommandDialogProps (line 26) | interface CommandDialogProps extends DialogProps {}
FILE: components/ui/form.tsx
type FormFieldContextValue (line 18) | type FormFieldContextValue<
type FormItemContextValue (line 65) | type FormItemContextValue = {
FILE: components/ui/input.tsx
type InputProps (line 5) | interface InputProps
FILE: components/ui/sheet.tsx
type SheetContentProps (line 52) | interface SheetContentProps
FILE: lib/utils.ts
function cn (line 4) | function cn(...inputs: ClassValue[]) {
FILE: scripts/seed.ts
function main (line 4) | async function main() {
Condensed preview — 96 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (171K chars).
[
{
"path": ".eslintrc.json",
"chars": 40,
"preview": "{\n \"extends\": \"next/core-web-vitals\"\n}\n"
},
{
"path": ".gitignore",
"chars": 396,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": "README.md",
"chars": 1383,
"preview": "This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js"
},
{
"path": "app/(auth)/layout.tsx",
"chars": 191,
"preview": "const AuthLayout = ({ children }: { children: React.ReactNode }) => {\n return (\n <div className=\"h-full flex items-c"
},
{
"path": "app/(auth)/sign-in/[[...sign-in]]/page.tsx",
"chars": 96,
"preview": "import { SignIn } from \"@clerk/nextjs\";\n\nexport default function Page() {\n return <SignIn />;\n}"
},
{
"path": "app/(auth)/sign-up/[[...sign-up]]/page.tsx",
"chars": 96,
"preview": "import { SignUp } from \"@clerk/nextjs\";\n\nexport default function Page() {\n return <SignUp />;\n}"
},
{
"path": "app/(course)/courses/[courseId]/layout.tsx",
"chars": 1048,
"preview": "import CourseSideBar from \"@/components/layout/CourseSideBar\";\nimport Topbar from \"@/components/layout/Topbar\";\nimport {"
},
{
"path": "app/(course)/courses/[courseId]/overview/page.tsx",
"chars": 2063,
"preview": "import { clerkClient } from \"@clerk/nextjs/server\";\nimport Image from \"next/image\";\nimport { redirect } from \"next/navig"
},
{
"path": "app/(course)/courses/[courseId]/sections/[sectionId]/page.tsx",
"chars": 1823,
"preview": "import SectionsDetails from \"@/components/sections/SectionsDetails\";\nimport { db } from \"@/lib/db\";\nimport { auth } from"
},
{
"path": "app/(home)/categories/[categoryId]/page.tsx",
"chars": 842,
"preview": "import getCoursesByCategory from \"@/app/actions/getCourses\";\nimport CourseCard from \"@/components/courses/CourseCard\";\ni"
},
{
"path": "app/(home)/layout.tsx",
"chars": 211,
"preview": "import Topbar from \"@/components/layout/Topbar\"\n\nconst HomeLayout = ({ children }: { children: React.ReactNode }) => {\n "
},
{
"path": "app/(home)/learning/page.tsx",
"chars": 1017,
"preview": "import CourseCard from \"@/components/courses/CourseCard\"\nimport { db } from \"@/lib/db\"\nimport { auth } from \"@clerk/next"
},
{
"path": "app/(home)/page.tsx",
"chars": 837,
"preview": "import { db } from \"@/lib/db\";\nimport getCoursesByCategory from \"../actions/getCourses\";\nimport Categories from \"@/compo"
},
{
"path": "app/(home)/search/page.tsx",
"chars": 1076,
"preview": "import CourseCard from \"@/components/courses/CourseCard\";\nimport { db } from \"@/lib/db\"\n\nconst SearchPage = async ({ sea"
},
{
"path": "app/(instructor)/instructor/courses/[courseId]/basic/page.tsx",
"chars": 2048,
"preview": "import EditCourseForm from \"@/components/courses/EditCourseForm\";\nimport AlertBanner from \"@/components/custom/AlertBann"
},
{
"path": "app/(instructor)/instructor/courses/[courseId]/sections/[sectionId]/page.tsx",
"chars": 1689,
"preview": "import AlertBanner from \"@/components/custom/AlertBanner\";\nimport EditSectionForm from \"@/components/sections/EditSectio"
},
{
"path": "app/(instructor)/instructor/courses/[courseId]/sections/page.tsx",
"chars": 760,
"preview": "import { auth } from \"@clerk/nextjs/server\";\nimport { redirect } from \"next/navigation\";\n\nimport CreateSectionForm from "
},
{
"path": "app/(instructor)/instructor/courses/page.tsx",
"chars": 874,
"preview": "import { auth } from \"@clerk/nextjs/server\";\nimport Link from \"next/link\";\nimport { redirect } from \"next/navigation\";\n\n"
},
{
"path": "app/(instructor)/instructor/create-course/page.tsx",
"chars": 641,
"preview": "import CreateCourseForm from \"@/components/courses/CreateCourseForm\"\nimport { db } from \"@/lib/db\"\n\nconst CreateCoursePa"
},
{
"path": "app/(instructor)/instructor/performance/page.tsx",
"chars": 786,
"preview": "import { getPerformance } from \"@/app/actions/getPerformance\"\nimport Chart from \"@/components/performance/Chart\"\nimport "
},
{
"path": "app/(instructor)/layout.tsx",
"chars": 592,
"preview": "import { auth } from \"@clerk/nextjs/server\";\nimport { redirect } from \"next/navigation\";\n\nimport Topbar from \"@/componen"
},
{
"path": "app/actions/getCourses.tsx",
"chars": 620,
"preview": "import { db } from \"@/lib/db\"\nimport { Course } from \"@prisma/client\"\n\nconst getCoursesByCategory = async (categoryId: s"
},
{
"path": "app/actions/getPerformance.ts",
"chars": 1371,
"preview": "import { db } from \"@/lib/db\";\nimport { Course, Purchase } from \"@prisma/client\";\n\ntype PurchaseWithCourse = Purchase & "
},
{
"path": "app/api/courses/[courseId]/checkout/route.ts",
"chars": 2425,
"preview": "import Stripe from \"stripe\";\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { currentUser } from \"@cler"
},
{
"path": "app/api/courses/[courseId]/publish/route.ts",
"chars": 1482,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/route.ts",
"chars": 1945,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/sections/[sectionId]/progress/route.ts",
"chars": 1698,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/sections/[sectionId]/publish/route.ts",
"chars": 1445,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/sections/[sectionId]/resources/[resourceId]/route.ts",
"chars": 1226,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/sections/[sectionId]/resources/route.ts",
"chars": 1249,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/sections/[sectionId]/route.ts",
"chars": 3644,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/sections/[sectionId]/unpublish/route.ts",
"chars": 1435,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/sections/reorder/route.ts",
"chars": 1044,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/sections/route.ts",
"chars": 1188,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/[courseId]/unpublish/route.ts",
"chars": 958,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/courses/route.ts",
"chars": 731,
"preview": "import { db } from \"@/lib/db\";\nimport { auth } from \"@clerk/nextjs/server\";\nimport { NextRequest, NextResponse } from \"n"
},
{
"path": "app/api/uploadthing/core.ts",
"chars": 877,
"preview": "import { auth } from \"@clerk/nextjs/server\";\nimport { createUploadthing, type FileRouter } from \"uploadthing/next\";\nimpo"
},
{
"path": "app/api/uploadthing/route.ts",
"chars": 214,
"preview": "import { createRouteHandler } from \"uploadthing/next\";\n \nimport { ourFileRouter } from \"./core\";\n \n// Export routes for "
},
{
"path": "app/api/webhook/route.ts",
"chars": 1215,
"preview": "import { db } from \"@/lib/db\";\nimport { stripe } from \"@/lib/stripe\";\nimport { headers } from \"next/headers\";\nimport { N"
},
{
"path": "app/globals.css",
"chars": 1806,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nhtml,\nbody,\n:root {\n height: 100%;\n}\n\n@layer base {\n :root"
},
{
"path": "app/layout.tsx",
"chars": 698,
"preview": "import type { Metadata } from \"next\";\nimport { Inter } from \"next/font/google\";\nimport { ClerkProvider } from \"@clerk/ne"
},
{
"path": "components/courses/Columns.tsx",
"chars": 1895,
"preview": "\"use client\";\n\nimport { Course } from \"@prisma/client\";\nimport { ColumnDef } from \"@tanstack/react-table\";\nimport { Penc"
},
{
"path": "components/courses/CourseCard.tsx",
"chars": 1939,
"preview": "import { db } from \"@/lib/db\";\nimport { clerkClient } from \"@clerk/nextjs/server\";\nimport { Course } from \"@prisma/clien"
},
{
"path": "components/courses/CreateCourseForm.tsx",
"chars": 4202,
"preview": "\"use client\";\n\nimport { z } from \"zod\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \""
},
{
"path": "components/courses/EditCourseForm.tsx",
"chars": 9147,
"preview": "\"use client\";\n\nimport { z } from \"zod\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \""
},
{
"path": "components/custom/AlertBanner.tsx",
"chars": 1028,
"preview": "import { Alert, AlertDescription, AlertTitle } from \"@/components/ui/alert\";\nimport { Rocket, TriangleAlert } from \"luci"
},
{
"path": "components/custom/Categories.tsx",
"chars": 1037,
"preview": "\"use client\"\n\nimport { Category } from \"@prisma/client\";\nimport { Button } from \"@/components/ui/button\";\nimport { useRo"
},
{
"path": "components/custom/ComboBox.tsx",
"chars": 2010,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronsUpDown } from \"lucide-react\"\n\nimport { cn } from \"@"
},
{
"path": "components/custom/DataTable.tsx",
"chars": 3779,
"preview": "\"use client\";\n\nimport {\n ColumnDef,\n flexRender,\n getCoreRowModel,\n getPaginationRowModel,\n SortingState,\n getSort"
},
{
"path": "components/custom/Delete.tsx",
"chars": 2268,
"preview": "import {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n Aler"
},
{
"path": "components/custom/FileUpload.tsx",
"chars": 1154,
"preview": "\"use client\";\n\nimport { ourFileRouter } from \"@/app/api/uploadthing/core\";\nimport { UploadDropzone } from \"@/lib/uploadt"
},
{
"path": "components/custom/PublishButton.tsx",
"chars": 1484,
"preview": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport axios from \"axios\";\nimport { Loader2 } from \"luci"
},
{
"path": "components/custom/ReadText.tsx",
"chars": 413,
"preview": "\"use client\"\n\nimport dynamic from \"next/dynamic\";\nimport { useMemo } from \"react\";\nimport \"react-quill/dist/quill.bubble"
},
{
"path": "components/custom/RichEditor.tsx",
"chars": 585,
"preview": "\"use client\"\n\nimport \"react-quill/dist/quill.snow.css\";\nimport dynamic from \"next/dynamic\";\nimport { useMemo } from \"rea"
},
{
"path": "components/layout/CourseSideBar.tsx",
"chars": 1909,
"preview": "import { db } from \"@/lib/db\";\nimport { Course, Section } from \"@prisma/client\";\nimport Link from \"next/link\";\nimport { "
},
{
"path": "components/layout/SectionMenu.tsx",
"chars": 1115,
"preview": "import { Course, Section } from \"@prisma/client\";\nimport React from \"react\";\nimport { Sheet, SheetContent, SheetTrigger "
},
{
"path": "components/layout/Sidebar.tsx",
"chars": 947,
"preview": "\"use client\";\n\nimport { BarChart4, MonitorPlay } from \"lucide-react\";\nimport Link from \"next/link\";\nimport { usePathname"
},
{
"path": "components/layout/Topbar.tsx",
"chars": 3680,
"preview": "\"use client\";\n\nimport { UserButton, useAuth } from \"@clerk/nextjs\";\nimport { Menu, Search } from \"lucide-react\";\nimport "
},
{
"path": "components/performance/Chart.tsx",
"chars": 888,
"preview": "\"use client\"\n\nimport {\n ResponsiveContainer,\n BarChart,\n Bar,\n XAxis,\n YAxis,\n Tooltip,\n} from \"recharts\";\nimport "
},
{
"path": "components/performance/DataCard.tsx",
"chars": 692,
"preview": "import { Card, CardContent, CardHeader, CardTitle } from \"@/components/ui/card\";\nimport { formatPrice } from \"@/lib/form"
},
{
"path": "components/providers/ToasterProvider.tsx",
"chars": 153,
"preview": "\"use client\"\n\nimport { Toaster } from \"react-hot-toast\"\n\nconst ToasterProvider = () => {\n return (\n <Toaster />\n )\n"
},
{
"path": "components/sections/CreateSectionForm.tsx",
"chars": 3965,
"preview": "\"use client\";\n\nimport { Course, Section } from \"@prisma/client\";\nimport Link from \"next/link\";\nimport { usePathname, use"
},
{
"path": "components/sections/EditSectionForm.tsx",
"chars": 6825,
"preview": "\"use client\";\n\nimport { z } from \"zod\";\nimport { zodResolver } from \"@hookform/resolvers/zod\";\nimport { useForm } from \""
},
{
"path": "components/sections/ProgressButton.tsx",
"chars": 1410,
"preview": "\"use client\";\n\nimport axios from \"axios\";\nimport { useRouter } from \"next/navigation\";\nimport { useState } from \"react\";"
},
{
"path": "components/sections/ResourceForm.tsx",
"chars": 4714,
"preview": "\"use client\";\n\nimport { Resource, Section } from \"@prisma/client\";\nimport Link from \"next/link\";\nimport { useRouter } fr"
},
{
"path": "components/sections/SectionList.tsx",
"chars": 2922,
"preview": "import { Section } from \"@prisma/client\";\nimport {\n DragDropContext,\n Droppable,\n Draggable,\n DropResult,\n} from \"@h"
},
{
"path": "components/sections/SectionsDetails.tsx",
"chars": 3159,
"preview": "\"use client\";\n\nimport {\n Course,\n MuxData,\n Progress,\n Purchase,\n Resource,\n Section,\n} from \"@prisma/client\";\nimp"
},
{
"path": "components/ui/alert-dialog.tsx",
"chars": 4434,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimpor"
},
{
"path": "components/ui/alert.tsx",
"chars": 1634,
"preview": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/"
},
{
"path": "components/ui/badge.tsx",
"chars": 1128,
"preview": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/"
},
{
"path": "components/ui/button.tsx",
"chars": 1917,
"preview": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class"
},
{
"path": "components/ui/card.tsx",
"chars": 1877,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n HTMLDivElement,\n Rea"
},
{
"path": "components/ui/command.tsx",
"chars": 4877,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { type DialogProps } from \"@radix-ui/react-dialog\"\nimport { Command "
},
{
"path": "components/ui/dialog.tsx",
"chars": 3849,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from"
},
{
"path": "components/ui/form.tsx",
"chars": 4085,
"preview": "import * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } from \"@radix-ui/r"
},
{
"path": "components/ui/input.tsx",
"chars": 824,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface InputProps\n extends React.InputHTMLA"
},
{
"path": "components/ui/label.tsx",
"chars": 724,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { cva, type "
},
{
"path": "components/ui/popover.tsx",
"chars": 1244,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } "
},
{
"path": "components/ui/progress.tsx",
"chars": 793,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ProgressPrimitive from \"@radix-ui/react-progress\"\n\nimport { cn "
},
{
"path": "components/ui/sheet.tsx",
"chars": 4281,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SheetPrimitive from \"@radix-ui/react-dialog\"\nimport { cva, type"
},
{
"path": "components/ui/switch.tsx",
"chars": 1155,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SwitchPrimitives from \"@radix-ui/react-switch\"\n\nimport { cn } f"
},
{
"path": "components/ui/table.tsx",
"chars": 2765,
"preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef<\n HTMLTableElement,\n "
},
{
"path": "components.json",
"chars": 341,
"preview": "{\n \"$schema\": \"https://ui.shadcn.com/schema.json\",\n \"style\": \"default\",\n \"rsc\": true,\n \"tsx\": true,\n \"tailwind\": {\n"
},
{
"path": "lib/db.ts",
"chars": 233,
"preview": "import { PrismaClient } from \"@prisma/client\";\n\ndeclare global {\n var prisma: PrismaClient | undefined\n}\n\nexport const "
},
{
"path": "lib/formatPrice.ts",
"chars": 154,
"preview": "export const formatPrice = (price: number) => {\n return new Intl.NumberFormat(\"en-US\", {\n style: \"currency\",\n cur"
},
{
"path": "lib/stripe.ts",
"chars": 145,
"preview": "import Stripe from 'stripe';\n\nexport const stripe = new Stripe(process.env.STRIPE_API_KEY!, {\n apiVersion: \"2024-04-10\""
},
{
"path": "lib/uploadthing.ts",
"chars": 294,
"preview": "import {\n generateUploadButton,\n generateUploadDropzone,\n} from \"@uploadthing/react\";\n \nimport type { OurFileRouter } "
},
{
"path": "lib/utils.ts",
"chars": 166,
"preview": "import { type ClassValue, clsx } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: Cla"
},
{
"path": "middleware.ts",
"chars": 181,
"preview": "import { clerkMiddleware } from \"@clerk/nextjs/server\";\n\nexport default clerkMiddleware();\n\nexport const config = {\n ma"
},
{
"path": "next.config.mjs",
"chars": 202,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n images: {\n remotePatterns: [\n { hostname: 'utfs."
},
{
"path": "package.json",
"chars": 1593,
"preview": "{\n \"name\": \"academy\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev\",\n \"build\": \"next"
},
{
"path": "postcss.config.mjs",
"chars": 135,
"preview": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n plugins: {\n tailwindcss: {},\n },\n};\n\nexport d"
},
{
"path": "prisma/schema.prisma",
"chars": 3602,
"preview": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\n// Looking for "
},
{
"path": "scripts/seed.ts",
"chars": 1862,
"preview": "const { PrismaClient } = require(\"@prisma/client\");\nconst database = new PrismaClient();\n\nasync function main() {\n try "
},
{
"path": "tailwind.config.ts",
"chars": 2179,
"preview": "import type { Config } from \"tailwindcss\"\n\nconst config = {\n darkMode: [\"class\"],\n content: [\n './pages/**/*.{ts,ts"
},
{
"path": "tsconfig.json",
"chars": 574,
"preview": "{\n \"compilerOptions\": {\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n \"skipLibCheck\": true,\n "
}
]
About this extraction
This page contains the full source code of the phuc-mai/academy GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 96 files (152.7 KB), approximately 41.2k tokens, and a symbol index with 36 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.