();
+
+ useEffect(() => {
+ setSearch(sp.get("q") ?? "");
+ }, [sp]);
+
+ return (
+
+
+ setSearch(evt.target.value)}
+ onKeyUp={(evt) => {
+ if (evt.key !== "Enter") {
+ return;
+ }
+
+ if (search) {
+ router.push(`/browse?q=${search}`);
+ } else {
+ router.push(`/browse`);
+ }
+ }}
+ />
+
+ );
+}
diff --git a/src/app/(consumer)/header.tsx b/src/app/(consumer)/header.tsx
new file mode 100644
index 0000000..bcfd57e
--- /dev/null
+++ b/src/app/(consumer)/header.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { ProfileAvatar } from "@/components/ui/profile-avatar";
+import { ProfilePlaceholder } from "@/components/ui/profile-placeholder";
+import { ThemeToggleButton } from "@/components/ui/theme-toggle-button";
+import { clearAuthCookies } from "@/lib/actions";
+import { User } from "@/lib/models";
+import {
+ Navbar,
+ NavbarBrand,
+ NavbarContent,
+ NavbarItem,
+ NavbarMenu,
+ NavbarMenuItem,
+ NavbarMenuToggle,
+} from "@nextui-org/navbar";
+import Link from "next/link";
+import { useState } from "react";
+import HeaderSearchField from "./header-search-field";
+
+export default function Header({ user }: { user?: User | null }) {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const themeButton = (
+ <>
+
+
+
+ >
+ );
+
+ const accountView = () => {
+ if (user) {
+ const isAdminOrOwner = user.role === "owner" || user.role === "admin";
+ return (
+
+
+
+
+
+
+
+
+
+
+ Signed in as
+ {user.nickname}
+
+
+ {user.role !== "user" && (
+ <>
+
+
+ Dashboard
+
+
+
+ >
+ )}
+
+ My Profile
+
+
+ My Learnings
+
+
+ My Bookmarks
+
+
+ {
+ await clearAuthCookies();
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ Log out
+
+
+
+ {themeButton}
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+ Account
+
+
+
+ Login
+
+
+ Sign Up
+
+
+
+ {themeButton}
+
+
+
+
+
+ Log In
+
+
+
+
+ Sign Up
+
+
+ {themeButton}
+
+ >
+ );
+ };
+
+ return (
+
+
+
+
+
+ {/* */}
+
+
+ {process.env.NEXT_PUBLIC_APP_NAME}
+
+
+
+
+
+
+ Browse
+
+
+
+
+ Categories
+
+
+
+
+ Blogs
+
+
+
+
+ Pricing
+
+
+
+
+ {accountView()}
+
+
+
+ setIsMenuOpen(false)}
+ >
+ Browse
+
+
+
+ setIsMenuOpen(false)}
+ >
+ Categories
+
+
+
+ setIsMenuOpen(false)}
+ >
+ Blogs
+
+
+
+ setIsMenuOpen(false)}
+ >
+ Pricing
+
+
+
+
+ );
+}
diff --git a/src/app/(consumer)/layout.tsx b/src/app/(consumer)/layout.tsx
new file mode 100644
index 0000000..709b1b1
--- /dev/null
+++ b/src/app/(consumer)/layout.tsx
@@ -0,0 +1,35 @@
+import { API_URL_LOCAL } from "@/lib/constants";
+import { User } from "@/lib/models";
+import { cookies } from "next/headers";
+import Footer from "./footer";
+import Header from "./header";
+
+const getUser = async () => {
+ const url = `${API_URL_LOCAL}/profile`;
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ next: { revalidate: 10 },
+ });
+
+ return resp.ok ? ((await resp.json()) as User) : null;
+};
+
+export default async function ConsumerLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const user = await getUser();
+
+ return (
+
+ );
+}
diff --git a/src/app/(consumer)/learn/[course]/course-menu.tsx b/src/app/(consumer)/learn/[course]/course-menu.tsx
new file mode 100644
index 0000000..24ed243
--- /dev/null
+++ b/src/app/(consumer)/learn/[course]/course-menu.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { DrawerContext } from "@/components/ui/drawer";
+import { Progress } from "@/components/ui/progress";
+import { Course, EnrolledCourse, Lesson } from "@/lib/models";
+import { cn } from "@/lib/utils";
+import { CheckCircle, Circle, X } from "lucide-react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { useContext } from "react";
+
+export default function CourseMenu({
+ course,
+ enrolledCourse,
+}: {
+ course: Course;
+ enrolledCourse: EnrolledCourse;
+}) {
+ const { isMenuOpen, toggle } = useContext(DrawerContext);
+
+ const params = useParams<{ course: string; lesson: string }>();
+
+ const isCompleted = (lesson: Lesson) => {
+ return (
+ enrolledCourse.completedLessons?.some((v) => v === lesson.id) ?? false
+ );
+ };
+
+ return (
+
+
+
{course.title}
+
+
+ {enrolledCourse.progress}% completed
+
+
+
+
+ {course.chapters?.map((c, i) => {
+ return (
+
+
+ {c.title}
+
+
+
+ {c.lessons?.map((l, i) => {
+ return (
+
+ {isCompleted(l) ? (
+
+ ) : (
+
+ )}
+ {
+ isMenuOpen && toggle?.();
+ }}
+ >
+ {l.title}
+
+
+ );
+ })}
+
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(consumer)/learn/[course]/layout.tsx b/src/app/(consumer)/learn/[course]/layout.tsx
new file mode 100644
index 0000000..cb42a3f
--- /dev/null
+++ b/src/app/(consumer)/learn/[course]/layout.tsx
@@ -0,0 +1,71 @@
+import { DrawerBackdrop, DrawerContextProvider } from "@/components/ui/drawer";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { Course, EnrolledCourse } from "@/lib/models";
+import { validateResponse } from "@/lib/validate-response";
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import CourseMenu from "./course-menu";
+
+interface Props {
+ params: { course: string };
+ children: React.ReactNode;
+}
+
+const getCourse = async (slug: string) => {
+ const url = `${API_URL_LOCAL}/content/courses/${slug}`;
+
+ const resp = await fetch(url, {
+ cache: "no-store",
+ });
+
+ if (resp.status === 204) {
+ return undefined;
+ }
+
+ return resp
+ .json()
+ .then((json) => json as Course)
+ .catch((e) => undefined);
+};
+
+const getEnrolledCourse = async (courseId: number) => {
+ const cookieStore = cookies();
+ const url = `${API_URL_LOCAL}/enrollments/${courseId}`;
+
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookieStore.toString(),
+ },
+ });
+
+ validateResponse(resp);
+
+ return resp
+ .json()
+ .then((json) => json as EnrolledCourse)
+ .catch(() => undefined);
+};
+
+export default async function ResumeCourseLayout({ params, children }: Props) {
+ const course = await getCourse(params.course);
+
+ if (!course) {
+ redirect("/profile/learnings");
+ }
+
+ const enrolledCourse = await getEnrolledCourse(course.id);
+
+ if (!enrolledCourse) {
+ redirect("/profile/learnings");
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/(consumer)/learn/[course]/lessons/[lesson]/drawer-toggle-button.tsx b/src/app/(consumer)/learn/[course]/lessons/[lesson]/drawer-toggle-button.tsx
new file mode 100644
index 0000000..3d6bd47
--- /dev/null
+++ b/src/app/(consumer)/learn/[course]/lessons/[lesson]/drawer-toggle-button.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { DrawerContext } from "@/components/ui/drawer";
+import { Menu } from "lucide-react";
+import { useContext } from "react";
+
+export default function DrawerToggleButton() {
+ const { isMenuOpen, toggle } = useContext(DrawerContext);
+
+ return (
+
+
+ Menu
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/(consumer)/learn/[course]/lessons/[lesson]/page.tsx b/src/app/(consumer)/learn/[course]/lessons/[lesson]/page.tsx
new file mode 100644
index 0000000..31e9569
--- /dev/null
+++ b/src/app/(consumer)/learn/[course]/lessons/[lesson]/page.tsx
@@ -0,0 +1,65 @@
+import { API_URL_LOCAL } from "@/lib/constants";
+import { Lesson, QuizResponse } from "@/lib/models";
+import { validateResponse } from "@/lib/validate-response";
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import ResumeCoursePage from "./resume-course-page";
+
+const getLesson = async (slug: string) => {
+ const url = `${API_URL_LOCAL}/enrollments/${slug}/lesson`;
+
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ });
+
+ validateResponse(resp);
+
+ if (resp.status === 204) {
+ return undefined;
+ }
+
+ return resp
+ .json()
+ .then((json) => json as Lesson)
+ .catch((e) => undefined);
+};
+
+const getQuizResponses = async (lessonId: number) => {
+ const url = `${API_URL_LOCAL}/enrollments/${lessonId}/quiz-responses`;
+
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ });
+
+ validateResponse(resp);
+
+ if (resp.status === 204) {
+ return undefined;
+ }
+
+ return resp
+ .json()
+ .then((json) => json as QuizResponse[])
+ .catch((e) => undefined);
+};
+
+export default async function ResumeCourse({
+ params,
+}: {
+ params: { course: string; lesson: string };
+}) {
+ const lesson = await getLesson(params.lesson);
+
+ if (!lesson) {
+ redirect(`/profile/learnings`);
+ }
+
+ const responses =
+ lesson.type === "quiz" ? await getQuizResponses(lesson.id) : undefined;
+
+ return ;
+}
diff --git a/src/app/(consumer)/learn/[course]/lessons/[lesson]/quiz-listing.tsx b/src/app/(consumer)/learn/[course]/lessons/[lesson]/quiz-listing.tsx
new file mode 100644
index 0000000..d707bc7
--- /dev/null
+++ b/src/app/(consumer)/learn/[course]/lessons/[lesson]/quiz-listing.tsx
@@ -0,0 +1,367 @@
+"use client";
+
+import { Input } from "@/components/forms";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { RadioButton } from "@/components/ui/radio-button";
+import { useToast } from "@/components/ui/use-toast";
+import { resetQuizResponse, submitQuizResponse } from "@/lib/actions";
+import { Lesson, Quiz, QuizAnswer, QuizResponse } from "@/lib/models";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { cn } from "@/lib/utils";
+import { Check, LoaderCircle, X } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useFieldArray, useForm } from "react-hook-form";
+
+interface QuizForm {
+ quiz: Quiz;
+ correct?: true | "partial";
+ answers: {
+ quizId: number;
+ answer: QuizAnswer;
+ selected?: boolean;
+ shortAnswer?: string;
+ }[];
+}
+
+export default function QuizListing({
+ lesson,
+ responses,
+}: {
+ lesson: Lesson;
+ responses: QuizResponse[];
+}) {
+ const mapToAnswer = (responses: QuizResponse[]) => {
+ if (responses.length === 0) {
+ return {};
+ }
+ const answerMap: { [answerId: string]: QuizResponse | undefined } = {};
+
+ for (const qr of responses) {
+ answerMap[qr.answer.id] = qr;
+ }
+
+ return answerMap;
+ };
+
+ const [answerMap, setAnswerMap] = useState<{
+ [answerId: string]: QuizResponse | undefined;
+ }>(() => mapToAnswer(responses));
+
+ const [isResetting, setResetting] = useState(false);
+
+ const { toast } = useToast();
+
+ const {
+ control,
+ formState: { isSubmitting },
+ handleSubmit,
+ setValue,
+ } = useForm<{
+ quizzes: QuizForm[];
+ }>();
+
+ const { fields, update } = useFieldArray({
+ control: control,
+ name: "quizzes",
+ keyName: "vId",
+ });
+
+ useEffect(() => {
+ const isSubmitted = Object.keys(answerMap).length > 0;
+ const quizzes =
+ lesson.quizzes?.map((q) => {
+ let correct: boolean | "partial" | undefined = undefined;
+ let correctCount = 0;
+ let incorrectCount = 0;
+
+ const answers: {
+ quizId: number;
+ answer: QuizAnswer;
+ selected?: boolean;
+ shortAnswer?: string;
+ }[] = [];
+
+ for (const ans of q.answers) {
+ const response = answerMap[ans.id];
+
+ if (q.type === "short_answer") {
+ const isCorrect = response && ans.answer === response.shortAnswer;
+ correct = isCorrect;
+ } else if (response && ans.correct) {
+ correct =
+ q.type === "multiple_choice" ? (correct ?? true) && true : true;
+ correctCount += 1;
+ } else if (response && !ans.correct) {
+ correct = false;
+ incorrectCount += 1;
+ } else if (!response && ans.correct && q.type === "multiple_choice") {
+ correct = false;
+ }
+
+ answers.push({
+ quizId: q.id,
+ answer: ans,
+ selected: !!response,
+ shortAnswer: response?.shortAnswer,
+ });
+ }
+
+ if (q.type === "multiple_choice") {
+ const totalCorrect = q.answers.filter((a) => a.correct).length;
+ if (incorrectCount > 0) {
+ correct = correctCount > 0 ? "partial" : correct;
+ } else {
+ correct = totalCorrect === correctCount ? true : "partial";
+ }
+ }
+
+ return {
+ quiz: q,
+ correct: isSubmitted ? correct : undefined,
+ answers: answers,
+ } as QuizForm;
+ }) ?? [];
+
+ setAnswerMap(answerMap);
+ setValue("quizzes", quizzes);
+ }, [lesson, answerMap, setValue]);
+
+ const submitQuiz = async (values: QuizForm[]) => {
+ try {
+ const body = values
+ .flatMap((f) => f.answers)
+ .filter((ans) => ans.selected === true)
+ .map((ans) => {
+ return {
+ quizId: ans.quizId,
+ answerId: ans.answer.id,
+ shortAnswer: ans.shortAnswer,
+ };
+ });
+
+ if (body.length === 0) {
+ throw "Required at least one answer";
+ }
+ const result = await submitQuizResponse(lesson.id, body);
+ setAnswerMap(mapToAnswer(result));
+ toast({
+ title: "Success",
+ description: "Quiz submitted",
+ variant: "success",
+ });
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ }
+ };
+
+ const resetQuiz = async () => {
+ try {
+ setResetting(true);
+ await resetQuizResponse(lesson.id);
+ setAnswerMap({});
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ } finally {
+ setResetting(false);
+ }
+ };
+
+ const isSubmitted = Object.keys(answerMap).length > 0;
+
+ const resultIcon = (isCorrect: boolean | undefined) => {
+ if (typeof isCorrect === "undefined") {
+ return null;
+ }
+ return (
+
+ {isCorrect ? (
+
+ ) : (
+
+ )}
+
+ );
+ };
+
+ const resultUI = (isCorrect: boolean | "partial" | undefined) => {
+ if (typeof isCorrect === "undefined") {
+ return null;
+ }
+
+ if (isCorrect === "partial") {
+ return (
+
+ Partially correct
+
+ );
+ }
+
+ if (isCorrect) {
+ return (
+
+ Correct
+
+ );
+ }
+ return (
+
+ Incorrect
+
+ );
+ };
+
+ const renderAnswers = (form: QuizForm, index: number) => {
+ const quiz = form.quiz;
+ return form.answers.map((a, i) => {
+ const ans = a.answer;
+ const resp = answerMap[a.answer.id];
+
+ let isCorrect: boolean | undefined = undefined;
+
+ if (isSubmitted && quiz.type === "short_answer" && resp) {
+ isCorrect = resp.shortAnswer?.trim() === a.answer.answer.trim();
+ } else if (isSubmitted && a.answer.correct && resp) {
+ isCorrect = true;
+ } else if (isSubmitted && resp) {
+ isCorrect = false;
+ }
+
+ if (quiz.type === "short_answer") {
+ return (
+
+ {
+ const value = evt.target.value.trim();
+ update(index, {
+ ...form,
+ answers: [{ ...a, shortAnswer: value, selected: !!value }],
+ });
+ }}
+ />
+ {resultIcon(isCorrect)}
+
+ );
+ }
+
+ if (quiz.type === "multiple_choice") {
+ return (
+
+ {
+ if (checked === "indeterminate") return;
+ const updatedAns = { ...a, selected: checked };
+ const answers = [...form.answers];
+ answers[i] = updatedAns;
+
+ update(index, {
+ ...form,
+ answers: answers,
+ });
+ }}
+ />
+
+ {ans.answer}
+
+ {resultIcon(isCorrect)}
+
+ );
+ }
+
+ return (
+ {
+ update(index, {
+ ...form,
+ answers: form.answers.map((v) => {
+ return {
+ ...v,
+ selected: v.answer.id === ans.id,
+ };
+ }),
+ });
+ }}
+ >
+
+ {ans.answer}
+ {resultIcon(isCorrect)}
+
+ );
+ });
+ };
+
+ return (
+
+ {fields.map((field, i) => {
+ const quiz = field.quiz;
+ return (
+
+
{quiz.question}
+ {renderAnswers(field, i)}
+
{resultUI(field.correct)}
+
+ );
+ })}
+
+ {isSubmitted && (
+
+ ({fields.filter((f) => f.correct).length}/
+ {lesson.quizzes?.length ?? 0}) Correct
+
+ )}
+
+
+
{
+ handleSubmit(async (values) => {
+ await submitQuiz(values.quizzes);
+ })();
+ }}
+ >
+ {isSubmitting && (
+
+ )}
+ Submit
+
+
+
+ {isResetting && }
+ Reset
+
+
+
+ );
+}
diff --git a/src/app/(consumer)/learn/[course]/lessons/[lesson]/resume-course-page.tsx b/src/app/(consumer)/learn/[course]/lessons/[lesson]/resume-course-page.tsx
new file mode 100644
index 0000000..596077f
--- /dev/null
+++ b/src/app/(consumer)/learn/[course]/lessons/[lesson]/resume-course-page.tsx
@@ -0,0 +1,225 @@
+"use client";
+
+import { AuthenticationContext } from "@/components/authentication-context-porvider";
+import { ContentRenderer } from "@/components/editor";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbList,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Separator } from "@/components/ui/separator";
+import { useToast } from "@/components/ui/use-toast";
+import { addCompletedLesson, removeCompletedLesson } from "@/lib/actions";
+import { Lesson, QuizResponse } from "@/lib/models";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { cn, formatRelativeTimestamp, pluralize } from "@/lib/utils";
+import { JSONContent } from "@tiptap/core";
+import { LoaderCircle, LockKeyhole } from "lucide-react";
+import Link from "next/link";
+import { useContext, useMemo, useState } from "react";
+import DrawerToggleButton from "./drawer-toggle-button";
+import QuizListing from "./quiz-listing";
+
+export default function ResumeCoursePage({
+ lesson,
+ responses,
+}: {
+ lesson: Lesson;
+ responses?: QuizResponse[];
+}) {
+ const { toast } = useToast();
+ const { user } = useContext(AuthenticationContext);
+
+ const [isSaving, setSaving] = useState(false);
+ const [isCompleted, setCompleted] = useState(lesson.completed ?? false);
+
+ const headings = useMemo(() => {
+ try {
+ if (lesson.type === "quiz") {
+ return [];
+ }
+
+ const json = lesson.lexical
+ ? (JSON.parse(lesson.lexical) as JSONContent)
+ : undefined;
+ if (!json) {
+ return [];
+ }
+
+ return (
+ json.content
+ ?.filter((v) => v.type === "heading")
+ .flatMap((v) => v.content)
+ .map((v) => v?.text ?? "")
+ .filter((v) => !!v) ?? []
+ );
+ } catch (error) {}
+ return [];
+ }, [lesson]);
+
+ const handleCompleted = async () => {
+ try {
+ setSaving(true);
+ const courseId = lesson.chapter?.course?.id;
+ const lessonId = lesson.id;
+ const path = `/learn/${lesson.chapter?.course?.slug}/lessons/${lesson.slug}`;
+ if (lesson.completed) {
+ await removeCompletedLesson(courseId ?? 0, lessonId, path);
+ } else {
+ await addCompletedLesson(courseId ?? 0, lessonId, path);
+ }
+ setCompleted(!lesson.completed);
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleResumed = async () => {
+ try {
+ } catch (error) {}
+ };
+
+ const content = () => {
+ const course = lesson.chapter?.course;
+ if (
+ course?.access === "premium" &&
+ (user?.expiredAt ?? 0) < new Date().getTime()
+ ) {
+ return (
+
+
+
+ You need to subscribe to view this content.
+
+
+ Subscribe
+
+
+ );
+ }
+
+ return (
+ <>
+ {lesson.title}
+
+
+
+ {lesson.type === "quiz" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {isSaving ? (
+
+ ) : (
+ <>
+
{
+ if (checked === "indeterminate") {
+ return;
+ }
+ handleCompleted();
+ }}
+ />
+
+ {isCompleted ? "Unmark completed" : "Mark as completed"}
+
+ >
+ )}
+
+
+ Last edited: {formatRelativeTimestamp(lesson.audit?.updatedAt)}
+
+
+ >
+ );
+ };
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {lesson.chapter?.title}
+
+
+
+
+
+ {content()}
+
+
+
+
+
On this content
+ {lesson.type === "quiz" ? (
+
+ {pluralize(lesson.quizzes?.length ?? 0, "Question")}
+
+ ) : (
+
+
+ {headings.map((h, i) => {
+ return (
+
+
+ {h}
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+ >
+ );
+}
diff --git a/src/app/(consumer)/learn/[course]/page.tsx b/src/app/(consumer)/learn/[course]/page.tsx
new file mode 100644
index 0000000..af6900c
--- /dev/null
+++ b/src/app/(consumer)/learn/[course]/page.tsx
@@ -0,0 +1,72 @@
+import { API_URL_LOCAL } from "@/lib/constants";
+import { Course, EnrolledCourse } from "@/lib/models";
+import { validateResponse } from "@/lib/validate-response";
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+
+const getCourse = async (slug: string) => {
+ const url = `${API_URL_LOCAL}/content/courses/${slug}`;
+
+ const resp = await fetch(url, {
+ next: { revalidate: 10 },
+ });
+
+ if (resp.status === 204) {
+ return undefined;
+ }
+
+ return resp
+ .json()
+ .then((json) => json as Course)
+ .catch((e) => undefined);
+};
+
+const getEnrolledCourse = async (courseId: number) => {
+ const cookieStore = cookies();
+ const url = `${API_URL_LOCAL}/enrollments/${courseId}`;
+
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookieStore.toString(),
+ },
+ });
+
+ validateResponse(resp);
+
+ return resp
+ .json()
+ .then((json) => json as EnrolledCourse)
+ .catch(() => undefined);
+};
+
+export default async function EnrolledCourse({
+ params,
+}: {
+ params: { course: string };
+}) {
+ const course = await getCourse(params.course);
+
+ if (!course) {
+ redirect("/profile/learnings");
+ }
+
+ const enrolledCourse = await getEnrolledCourse(course.id);
+
+ if (!enrolledCourse) {
+ redirect("/profile/learnings");
+ }
+
+ if (enrolledCourse.resumeLesson) {
+ redirect(
+ `/learn/${params.course}/lessons/${enrolledCourse.resumeLesson.slug}`
+ );
+ }
+
+ const firstLesson = course.chapters?.[0].lessons?.[0];
+
+ if (!firstLesson) {
+ redirect("/profile/learnings");
+ }
+
+ redirect(`/learn/${params.course}/lessons/${firstLesson.slug}`);
+}
diff --git a/src/app/(consumer)/login/login-page.tsx b/src/app/(consumer)/login/login-page.tsx
new file mode 100644
index 0000000..2f31c27
--- /dev/null
+++ b/src/app/(consumer)/login/login-page.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import { Input, PasswordInput } from "@/components/forms";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardFooter } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { applyAuthCookies } from "@/lib/actions";
+import { firebaseAuth } from "@/lib/firebase.config";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { inMemoryPersistence, signInWithEmailAndPassword } from "firebase/auth";
+import { LoaderCircle } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+const schema = z.object({
+ username: z.string().email({
+ message: "Please enter valid email address",
+ }),
+ password: z.string().min(1, {
+ message: "Please enter password",
+ }),
+});
+
+type LoginForm = z.infer;
+
+function LoginPage() {
+ const [error, setError] = useState();
+ const [oauthLogin, setOauthLogin] = useState<"facebook" | "google">();
+
+ const {
+ register,
+ formState: { errors, isSubmitting },
+ handleSubmit,
+ } = useForm({
+ resolver: zodResolver(schema),
+ });
+
+ const handleLogin = async (values: LoginForm) => {
+ try {
+ setError(undefined);
+ const auth = firebaseAuth;
+ auth.setPersistence(inMemoryPersistence);
+ const result = await signInWithEmailAndPassword(
+ auth,
+ values.username,
+ values.password
+ );
+
+ const idToken = await result.user.getIdToken();
+ const refreshToken = result.user.refreshToken;
+
+ await auth.signOut();
+ await applyAuthCookies({
+ accessToken: idToken,
+ refreshToken: refreshToken,
+ });
+ } catch (error) {
+ setError(parseErrorResponse(error));
+ }
+ };
+
+ return (
+
+
+
+
+ Sign In
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ Don't have an account?
+
+ Sign Up
+
+
+
+
+
+ );
+}
+
+export default LoginPage;
diff --git a/src/app/(consumer)/login/page.tsx b/src/app/(consumer)/login/page.tsx
new file mode 100644
index 0000000..758ee8b
--- /dev/null
+++ b/src/app/(consumer)/login/page.tsx
@@ -0,0 +1,22 @@
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import LoginPage from "./login-page";
+import { revalidatePath } from "next/cache";
+import { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Login",
+ description: process.env.NEXT_PUBLIC_APP_DESC,
+};
+
+export default async function Login() {
+ const cookieStore = cookies();
+ const accessToken = cookieStore.get("access_token")?.value;
+
+ if (accessToken) {
+ revalidatePath("/", "layout")
+ redirect("/");
+ }
+
+ return ;
+}
diff --git a/src/app/(consumer)/page.tsx b/src/app/(consumer)/page.tsx
new file mode 100644
index 0000000..c7ff565
--- /dev/null
+++ b/src/app/(consumer)/page.tsx
@@ -0,0 +1,119 @@
+import { BlogGridItem } from "@/components/blog";
+import { CourseGridItem } from "@/components/course";
+import { Button } from "@/components/ui/button";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { Course, Page, Post } from "@/lib/models";
+import { buildQueryParams } from "@/lib/utils";
+import Link from "next/link";
+import BannerImage from "./banner-image";
+import QuoteSwiper from "./quote-swiper";
+
+const getTopCourses = async () => {
+ const query = buildQueryParams({ limit: 8, orderBy: "enrollment" });
+
+ const url = `${API_URL_LOCAL}/content/courses${query}`;
+
+ const resp = await fetch(url, {
+ cache: "no-store",
+ });
+
+ return resp
+ .json()
+ .then((json) => json as Page)
+ .catch((e) => undefined);
+};
+
+const getRecentPosts = async () => {
+ const query = buildQueryParams({ limit: 4, orderBy: "publishedAt" });
+
+ const url = `${API_URL_LOCAL}/content/posts${query}`;
+
+ const resp = await fetch(url, {
+ cache: "no-store",
+ });
+
+ return resp
+ .json()
+ .then((json) => json as Page)
+ .catch((e) => undefined);
+};
+
+export default async function Home() {
+ const topCoursesPromise = getTopCourses();
+ const recentPostsPromise = getRecentPosts();
+
+ const [topCourses, recentPosts] = await Promise.all([
+ topCoursesPromise,
+ recentPostsPromise,
+ ]);
+
+ const topCoursesUI = (list: Course[]) => {
+ if (list.length === 0) {
+ return No content found
;
+ }
+
+ return list.map((c) => {
+ return ;
+ });
+ };
+
+ const recentPostsUI = (list: Post[]) => {
+ if (list.length === 0) {
+ return No content found
;
+ }
+
+ return list.map((p) => {
+ return ;
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+ Welcome to [sitename]
+
+
+ Hi, Welcome to [sitename]. Start a new career in the software
+ developing industry.
+
+
+
+ Browse courses
+
+
+
+
+
+
+
+
+
+
+
+
Top courses
+
+ {topCoursesUI(topCourses?.contents ?? [])}
+
+
+
Recent posts
+
+ {recentPostsUI(recentPosts?.contents ?? [])}
+
+
+ >
+ );
+}
diff --git a/src/app/(consumer)/posts/[slug]/page.tsx b/src/app/(consumer)/posts/[slug]/page.tsx
new file mode 100644
index 0000000..cf85ec7
--- /dev/null
+++ b/src/app/(consumer)/posts/[slug]/page.tsx
@@ -0,0 +1,248 @@
+import { ContentRenderer } from "@/components/editor";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { ProfileAvatar } from "@/components/ui/profile-avatar";
+import { ScrollToTop } from "@/components/ui/scroll-to-top";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { Post, User } from "@/lib/models";
+import {
+ cn,
+ formatAbbreviate,
+ formatRelativeTimestamp,
+ wordPerMinute,
+} from "@/lib/utils";
+import { LockKeyhole } from "lucide-react";
+import { Metadata, ResolvingMetadata } from "next";
+import { cookies } from "next/headers";
+import Image from "next/image";
+import Link from "next/link";
+import { cache } from "react";
+
+interface Props {
+ params: { slug: string };
+}
+
+const getPost = cache(async (slug: string) => {
+ const url = `${API_URL_LOCAL}/content/posts/${slug}`;
+
+ const resp = await fetch(url, {
+ cache: "no-store",
+ });
+
+ if (!resp.ok || resp.status === 204) {
+ return undefined;
+ }
+
+ return resp
+ .json()
+ .then((json) => json as Post)
+ .catch((e) => undefined);
+});
+
+const getUser = async () => {
+ const url = `${API_URL_LOCAL}/profile`;
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ next: { revalidate: 10 },
+ });
+
+ return resp.ok ? ((await resp.json()) as User) : null;
+};
+
+export async function generateMetadata(
+ { params }: Props,
+ parent: ResolvingMetadata
+): Promise {
+ try {
+ const post = await getPost(params.slug);
+
+ const previousImages = (await parent).openGraph?.images || [];
+
+ if (post) {
+ const title = post.title ?? "(Untitled)";
+ const desc = post.excerpt ?? title;
+ return {
+ title: title,
+ description: desc,
+ openGraph: {
+ url: `${process.env.NEXT_PUBLIC_BASE_URL}/blogs/${post.slug}`,
+ title: title,
+ description: desc,
+ images: [`${post.cover ?? ""}`, ...previousImages],
+ type: "website",
+ },
+ twitter: {
+ title: title,
+ description: desc,
+ card: "summary_large_image",
+ images: [`${post.cover ?? ""}`, ...previousImages],
+ },
+ };
+ }
+ } catch (error) {}
+
+ return {
+ title: "Post not found",
+ };
+}
+
+export default async function BlogPost({ params }: Props) {
+ const postPromise = getPost(params.slug);
+ const userPromise = getUser();
+
+ const [post, user] = await Promise.all([postPromise, userPromise]);
+
+ if (!post) {
+ return (
+
+ );
+ }
+
+ const authorsView = () => {
+ const authorCount = post.authors?.length ?? 0;
+
+ return (
+ 1 ? "-ml-[3px]" : undefined
+ )}
+ >
+
+ {post.authors?.map((a, i) => {
+ return (
+
0 ? "ml-[-27px]" : undefined,
+ authorCount > 1 ? "border-3 border-background" : undefined
+ )}
+ style={{
+ zIndex: authorCount - i,
+ }}
+ />
+ );
+ })}
+
+
+
+ By {post.authors?.map((a) => a.nickname).join(", ")}
+
+
+ {formatRelativeTimestamp(post.publishedAt)}
+
+
+
+
+ {/*
+
+
+
+
+
+
+
+ Share
+
+ */}
+
+ );
+ };
+
+ const content = () => {
+ if (post.visibility === "member" && !user) {
+ return (
+
+
+
+ You need to sign in to view this content.
+
+
+ Sign In
+
+
+ );
+ }
+
+ if (
+ post.visibility === "paid_member" &&
+ (!user || user.expiredAt < new Date().getTime())
+ ) {
+ return (
+
+
+
+ You need to subscribe to view this content.
+
+
+ Subscribe
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {post.tags?.map((t) => {
+ return (
+
+ {t.name}
+
+ );
+ })}
+
+ >
+ );
+ };
+
+ return (
+ <>
+
+
+
{post.title ?? "(Untitled)"}
+
+
+ {wordPerMinute(post.wordCount)} min read
+
+
•
+
+ {formatAbbreviate(BigInt(post.meta?.viewCount ?? 0))} views
+
+
+
+ {authorsView()}
+
+
+
+
+
+ {content()}
+
+ {/*
+
+ {authorsView()} */}
+
+ >
+ );
+}
diff --git a/src/app/(consumer)/pricing/page.tsx b/src/app/(consumer)/pricing/page.tsx
new file mode 100644
index 0000000..191fbf8
--- /dev/null
+++ b/src/app/(consumer)/pricing/page.tsx
@@ -0,0 +1,98 @@
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { Metadata } from "next";
+import Link from "next/link";
+
+export const metadata: Metadata = {
+ title: "Pricing",
+ description: process.env.NEXT_PUBLIC_APP_DESC,
+};
+
+export default function Pricing() {
+ return (
+
+
+ Choose a plan for your learning journey
+
+
+
+
+ Basic
+
+
+
+ $0
+
+ Always free
+
+
+
+ Access to all free courses and article contents
+
+
+
+ Sign up
+
+
+
+
+
+ Monthly
+
+
+
+
+
$15
+ /month
+
+
+ Billed monthly
+
+
+
+ Everything in basic plus paid courses and articles
+
+
+
+ Subscribe
+
+
+
+
+
+ Annually
+
+
+
+
+
$12
+ /month
+
+
+ Billed annually. Saved 20% monthly.
+
+
+
+ Everything in basic plus paid courses and articles
+
+
+
+ Subscribe
+
+
+
+
+
+ );
+}
diff --git a/src/app/(consumer)/privacy-policy/page.tsx b/src/app/(consumer)/privacy-policy/page.tsx
new file mode 100644
index 0000000..c4c391c
--- /dev/null
+++ b/src/app/(consumer)/privacy-policy/page.tsx
@@ -0,0 +1,31 @@
+import { ContentRenderer } from "@/components/editor";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Privacy Policy",
+ description: process.env.NEXT_PUBLIC_APP_DESC,
+};
+
+const getPrivacyPolicy = async () => {
+ const url = `${API_URL_LOCAL}/content/site-settings/privacy-policy`;
+ const resp = await fetch(url, {
+ cache: "no-store",
+ });
+
+ return resp
+ .json()
+ .then((json) => json)
+ .catch((e) => undefined);
+};
+
+export default async function PrivacyPolicy() {
+ const privacyPolicy = await getPrivacyPolicy();
+
+ return (
+
+
Privacy Policy
+
+
+ );
+}
diff --git a/src/app/(consumer)/profile/bookmarks/page.tsx b/src/app/(consumer)/profile/bookmarks/page.tsx
new file mode 100644
index 0000000..935e8fb
--- /dev/null
+++ b/src/app/(consumer)/profile/bookmarks/page.tsx
@@ -0,0 +1,86 @@
+import { BookmarkCourseGridItem } from "@/components/course";
+import { Alert } from "@/components/ui/alert";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import Pagination from "@/components/ui/pagination";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { Course, Page } from "@/lib/models";
+import { buildQueryParams } from "@/lib/utils";
+import { validateResponse } from "@/lib/validate-response";
+import { cookies } from "next/headers";
+import Link from "next/link";
+
+interface Props {
+ searchParams: { [key: string]: string | undefined };
+}
+
+const getBookmarks = async ({ searchParams }: Props) => {
+ const query = buildQueryParams({ page: searchParams["page"], limit: 15 });
+
+ const url = `${API_URL_LOCAL}/profile/bookmarks${query}`;
+
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ });
+
+ validateResponse(resp);
+
+ return resp
+ .json()
+ .then((json) => json as Page)
+ .catch((e) => undefined);
+};
+
+export default async function Bookmarks(props: Props) {
+ const bookmarks = await getBookmarks(props);
+
+ const content = () => {
+ if (!bookmarks?.contents.length) {
+ return No courses bookmarked ;
+ }
+
+ return (
+ <>
+
+ {bookmarks.contents.map((bm, i) => {
+ return ;
+ })}
+
+
+
+ >
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+ Profile
+
+
+
+
+ Bookmarks
+
+
+
+ {content()}
+ >
+ );
+}
diff --git a/src/app/(consumer)/profile/layout.tsx b/src/app/(consumer)/profile/layout.tsx
new file mode 100644
index 0000000..9b358d6
--- /dev/null
+++ b/src/app/(consumer)/profile/layout.tsx
@@ -0,0 +1,86 @@
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion";
+import { BookText, Bookmark, Settings, User } from "lucide-react";
+import Link from "next/link";
+import { ReactNode } from "react";
+
+const iconSize = 20;
+
+export default function ProfileLayout({ children }: { children: ReactNode }) {
+ function menuLink({
+ href,
+ title,
+ icon,
+ }: {
+ href: string;
+ title: string;
+ icon: ReactNode;
+ }) {
+ return (
+
+ {icon}
+ {title}
+
+ );
+ }
+
+ const content = (
+ <>
+
+ ACCOUNT
+
+
+ {menuLink({
+ href: "/profile",
+ title: "Profile",
+ icon: ,
+ })}
+ {menuLink({
+ href: "/profile/learnings",
+ title: "Learnings",
+ icon: ,
+ })}
+ {menuLink({
+ href: "/profile/bookmarks",
+ title: "Bookmarks",
+ icon: ,
+ })}
+ {menuLink({
+ href: "/profile/setting",
+ title: "Setting",
+ icon: ,
+ })}
+
+ >
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ Menu
+
+
+ {content}
+
+
+
+
+
+
{content}
+
+
+
{children}
+
+
+ );
+}
diff --git a/src/app/(consumer)/profile/learnings/page.tsx b/src/app/(consumer)/profile/learnings/page.tsx
new file mode 100644
index 0000000..a99ba2e
--- /dev/null
+++ b/src/app/(consumer)/profile/learnings/page.tsx
@@ -0,0 +1,94 @@
+import { EnrolledCourseGridItem } from "@/components/course";
+import { Alert } from "@/components/ui/alert";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+import Pagination from "@/components/ui/pagination";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { EnrolledCourse, Page } from "@/lib/models";
+import { buildQueryParams } from "@/lib/utils";
+import { validateResponse } from "@/lib/validate-response";
+import { cookies } from "next/headers";
+import Link from "next/link";
+
+interface Props {
+ searchParams: { [key: string]: string | undefined };
+}
+
+const getEnrollments = async ({ searchParams }: Props) => {
+ const query = buildQueryParams({ page: searchParams["page"], limit: 15 });
+
+ const url = `${API_URL_LOCAL}/profile/enrollments${query}`;
+
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ });
+
+ validateResponse(resp);
+
+ return resp
+ .json()
+ .then((json) => json as Page)
+ .catch((e) => undefined);
+};
+
+export default async function Learnings(props: Props) {
+ const enrollments = await getEnrollments(props);
+
+ const content = () => {
+ if (!enrollments || enrollments.contents.length === 0) {
+ return (
+
+ No courses enrolled.
+
+ Browse
+
+ courses.
+
+ );
+ }
+
+ return (
+ <>
+
+ {enrollments.contents.map((ec, i) => {
+ return ;
+ })}
+
+
+
+ >
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+ Profile
+
+
+
+
+ Learnings
+
+
+
+ {content()}
+ >
+ );
+}
diff --git a/src/app/(consumer)/profile/page.tsx b/src/app/(consumer)/profile/page.tsx
new file mode 100644
index 0000000..02fe193
--- /dev/null
+++ b/src/app/(consumer)/profile/page.tsx
@@ -0,0 +1,92 @@
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { ProfileAvatar } from "@/components/ui/profile-avatar";
+import { Separator } from "@/components/ui/separator";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { User, UserMeta } from "@/lib/models";
+import { formatNumber } from "@/lib/utils";
+import { validateResponse } from "@/lib/validate-response";
+import { cookies } from "next/headers";
+import Link from "next/link";
+
+const getUser = async () => {
+ const url = `${API_URL_LOCAL}/profile`;
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ next: { revalidate: 10 },
+ });
+
+ return resp.ok ? ((await resp.json()) as User) : null;
+};
+
+const getUserMeta = async () => {
+ const url = `${API_URL_LOCAL}/profile/meta`;
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ });
+
+ validateResponse(resp);
+
+ return resp
+ .json()
+ .then((json) => json as UserMeta)
+ .catch((e) => undefined);
+};
+
+export default async function Profile() {
+ const userPromise = getUser();
+ const metaPromise = getUserMeta();
+
+ const [user, meta] = await Promise.all([userPromise, metaPromise]);
+
+ return (
+
+
+
+
+
+
{user?.nickname}
+ {user?.email}
+
+
+
+ Edit
+
+
+
+
+ Overview
+
+
+
+
+ {formatNumber(meta?.enrollmentCount ?? 0)}
+
+ Learnings
+
+
+
+
+
{formatNumber(meta?.bookmarkCount ?? 0)}
+ Bookmarks
+
+
+
+
+
+ );
+}
diff --git a/src/app/(consumer)/profile/setting/change-password.tsx b/src/app/(consumer)/profile/setting/change-password.tsx
new file mode 100644
index 0000000..f3de845
--- /dev/null
+++ b/src/app/(consumer)/profile/setting/change-password.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { AuthenticationContext } from "@/components/authentication-context-porvider";
+import { PasswordInput } from "@/components/forms";
+import { Button } from "@/components/ui/button";
+import { useToast } from "@/components/ui/use-toast";
+import { firebaseAuth } from "@/lib/firebase.config";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+ AuthErrorCodes,
+ inMemoryPersistence,
+ signInWithEmailAndPassword,
+ updatePassword,
+} from "firebase/auth";
+import { LoaderCircle } from "lucide-react";
+import { useContext } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+const schema = z
+ .object({
+ oldPassword: z.string().min(1, {
+ message: "Required old password",
+ }),
+ newPassword: z.string().min(8, {
+ message: "Password must be at least 8 characters",
+ }),
+ confirmPassword: z.string(),
+ })
+ .refine((data) => data.newPassword === data.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
+ });
+
+type ChangePasswordForm = z.infer;
+
+export default function ChangePassword() {
+ const { toast } = useToast();
+ const { user } = useContext(AuthenticationContext);
+
+ const {
+ register,
+ formState: { errors, isSubmitting },
+ handleSubmit,
+ reset,
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {},
+ });
+
+ const handleChangePassowrd = async (values: ChangePasswordForm) => {
+ try {
+ if (!user?.email) {
+ throw "Something went wrong. Please try again";
+ }
+ const auth = firebaseAuth;
+ auth.setPersistence(inMemoryPersistence);
+ const result = await signInWithEmailAndPassword(
+ auth,
+ user.email,
+ values.oldPassword
+ );
+ await updatePassword(result.user, values.newPassword);
+ await auth.signOut();
+ toast({
+ title: "Success",
+ description: "Password changed successfully",
+ variant: "success",
+ });
+ reset();
+ } catch (error: any) {
+ let msg = parseErrorResponse(error);
+ if (error.code === AuthErrorCodes.INVALID_PASSWORD) {
+ msg = "Current password incorrect";
+ } else if (error.code === AuthErrorCodes.INVALID_LOGIN_CREDENTIALS) {
+ msg = "Current password incorrect";
+ }
+ toast({
+ title: "Error",
+ description: msg,
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/(consumer)/profile/setting/page.tsx b/src/app/(consumer)/profile/setting/page.tsx
new file mode 100644
index 0000000..398aca2
--- /dev/null
+++ b/src/app/(consumer)/profile/setting/page.tsx
@@ -0,0 +1,37 @@
+import { Card, CardContent } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { User } from "@/lib/models";
+import { cookies } from "next/headers";
+import { redirect } from "next/navigation";
+import ChangePassword from "./change-password";
+import ProfileUpdate from "./profile-update";
+
+const getUser = async () => {
+ const url = `${API_URL_LOCAL}/profile`;
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ });
+
+ return resp.ok ? ((await resp.json()) as User) : null;
+};
+
+export default async function Setting() {
+ const user = await getUser();
+
+ if (!user) {
+ redirect("/");
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(consumer)/profile/setting/profile-update.tsx b/src/app/(consumer)/profile/setting/profile-update.tsx
new file mode 100644
index 0000000..56aaf8d
--- /dev/null
+++ b/src/app/(consumer)/profile/setting/profile-update.tsx
@@ -0,0 +1,194 @@
+"use client";
+
+import { Input } from "@/components/forms";
+import { Button } from "@/components/ui/button";
+import { ProfileAvatar } from "@/components/ui/profile-avatar";
+import { useToast } from "@/components/ui/use-toast";
+import { updateUserProfile, uploadImage } from "@/lib/actions";
+import { User } from "@/lib/models";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { LoaderCircle } from "lucide-react";
+
+import { ChangeEvent, useRef, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+const schema = z.object({
+ nickname: z.string().min(1, {
+ message: "Please enter nickname",
+ }),
+ headline: z.string().optional(),
+ email: z.string().optional(),
+ username: z.string(),
+});
+
+type ProfileUpdateForm = z.infer;
+
+export default function ProfileUpdate({ user }: { user: User }) {
+ const [isUploading, setUploading] = useState(false);
+ const { toast } = useToast();
+ const imageFileRef = useRef(null);
+
+ const {
+ register,
+ formState: { errors, isSubmitting },
+ handleSubmit,
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ nickname: user.nickname,
+ headline: user.headline,
+ email: user.email,
+ username: user.username,
+ },
+ });
+
+ const handleUpdate = async (values: ProfileUpdateForm) => {
+ try {
+ await updateUserProfile(values);
+ toast({
+ title: "Success",
+ description: "Profile updated",
+ variant: "success",
+ });
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ }
+ };
+
+ const handleCoverUpload = async (event: ChangeEvent) => {
+ try {
+ const files = event.target.files;
+ if (files && files.length > 0) {
+ const file = files[0];
+ const fileSize = file.size / (1024 * 1024);
+
+ if (fileSize > 1) {
+ throw "File size too big (max 1MB).";
+ }
+
+ setUploading(true);
+ const form = new FormData();
+ form.append("file", file);
+ const url = await uploadImage(form);
+ await updateUserProfile({
+ nickname: user.nickname,
+ headline: user.headline,
+ email: user.email,
+ username: user.username,
+ image: url,
+ });
+
+ toast({
+ title: "Success",
+ description: "Profile image updated",
+ variant: "success",
+ });
+ }
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ } finally {
+ event.target.value = "";
+ setUploading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/(consumer)/profile/setting/setting-page.tsx b/src/app/(consumer)/profile/setting/setting-page.tsx
new file mode 100644
index 0000000..db2b5fc
--- /dev/null
+++ b/src/app/(consumer)/profile/setting/setting-page.tsx
@@ -0,0 +1,37 @@
+import { Card, CardContent } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import ChangePassword from "./change-password";
+import ProfileUpdate from "./profile-update";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { User } from "@/lib/models";
+import { cookies } from "next/headers";
+
+const getUser = async () => {
+ const url = `${API_URL_LOCAL}/profile`;
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ next: { revalidate: 10 },
+ });
+
+ return resp.ok ? ((await resp.json()) as User) : null;
+};
+
+export default async function SettingPage() {
+ const user = await getUser();
+
+ if (!user) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/(consumer)/quote-swiper.tsx b/src/app/(consumer)/quote-swiper.tsx
new file mode 100644
index 0000000..eee1492
--- /dev/null
+++ b/src/app/(consumer)/quote-swiper.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { Autoplay, Pagination } from "swiper/modules";
+import { Swiper, SwiperSlide } from "swiper/react";
+
+const quotes = [
+ {
+ quote:
+ "“For the things we have to learn before we can do them, we learn by doing them.”",
+ author: "Aristotle",
+ },
+ {
+ quote: "“An investment in knowledge pays the best interest.”",
+ author: "Benjamin Franklin",
+ },
+ {
+ quote: "“Learning never exhausts the mind.”",
+ author: "Leonardo da Vinci",
+ },
+];
+
+export default function QuoteSwiper() {
+ return (
+
+ {quotes.map((q, i) => {
+ return (
+
+
+
+ {q.quote}
+
+
+ ~ {q.author}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/app/(consumer)/sign-up/page.tsx b/src/app/(consumer)/sign-up/page.tsx
new file mode 100644
index 0000000..f280da5
--- /dev/null
+++ b/src/app/(consumer)/sign-up/page.tsx
@@ -0,0 +1,21 @@
+import { cookies } from "next/headers";
+import SignUpPage from "./signup-page";
+import { redirect } from "next/navigation";
+import { revalidatePath } from "next/cache";
+import { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Sign Up",
+ description: process.env.NEXT_PUBLIC_APP_DESC,
+};
+
+export default function SignUp() {
+ const cookieStore = cookies();
+ const accessToken = cookieStore.get("access_token")?.value;
+
+ if (accessToken) {
+ revalidatePath("/", "layout")
+ redirect("/");
+ }
+ return ;
+}
diff --git a/src/app/(consumer)/sign-up/signup-page.tsx b/src/app/(consumer)/sign-up/signup-page.tsx
new file mode 100644
index 0000000..f136f8b
--- /dev/null
+++ b/src/app/(consumer)/sign-up/signup-page.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import { Input, PasswordInput } from "@/components/forms";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardFooter } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { applyAuthCookies } from "@/lib/actions";
+import { firebaseAuth } from "@/lib/firebase.config";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { zodResolver } from "@hookform/resolvers/zod";
+import {
+ createUserWithEmailAndPassword,
+ inMemoryPersistence,
+ sendEmailVerification,
+ updateProfile,
+} from "firebase/auth";
+import { LoaderCircle } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+const schema = z
+ .object({
+ nickname: z.string().min(2, {
+ message: "Please your enter nick name",
+ }),
+ email: z.string().email({
+ message: "Please enter valid email address",
+ }),
+ password: z.string().min(8, {
+ message: "Password must be at least 8 characters",
+ }),
+ confirmPassword: z.string(),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
+ });
+
+type SignUpForm = z.infer;
+
+function SignUpPage() {
+ const [error, setError] = useState();
+ const [oauthLogin, setOauthLogin] = useState<"facebook" | "google">();
+
+ const {
+ register,
+ formState: { errors, isSubmitting },
+ handleSubmit,
+ } = useForm({
+ resolver: zodResolver(schema),
+ });
+
+ const handleSignUp = async (values: SignUpForm) => {
+ try {
+ setError(undefined);
+ const auth = firebaseAuth;
+ auth.setPersistence(inMemoryPersistence);
+ const result = await createUserWithEmailAndPassword(
+ auth,
+ values.email,
+ values.password
+ );
+ sendEmailVerification(result.user);
+ await updateProfile(result.user, {
+ displayName: values.nickname,
+ });
+
+ const idToken = await result.user.getIdToken();
+ const refreshToken = result.user.refreshToken;
+
+ await auth.signOut();
+ await applyAuthCookies({
+ accessToken: idToken,
+ refreshToken: refreshToken,
+ });
+ } catch (error) {
+ setError(parseErrorResponse(error));
+ }
+ };
+
+ return (
+
+
+
+
+ Sign Up
+
+ {error && {error} }
+
+
+
+
+
+ Already have an account?
+
+ Login
+
+
+
+
+
+ );
+}
+
+export default SignUpPage;
diff --git a/src/app/(consumer)/terms-and-conditions/page.tsx b/src/app/(consumer)/terms-and-conditions/page.tsx
new file mode 100644
index 0000000..b9b45d9
--- /dev/null
+++ b/src/app/(consumer)/terms-and-conditions/page.tsx
@@ -0,0 +1,31 @@
+import { ContentRenderer } from "@/components/editor";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Terms And Conditions",
+ description: process.env.NEXT_PUBLIC_APP_DESC,
+};
+
+const getTermsAndConditions = async () => {
+ const url = `${API_URL_LOCAL}/content/site-settings/terms-and-conditions`;
+ const resp = await fetch(url, {
+ cache: "no-store",
+ });
+
+ return resp
+ .json()
+ .then((json) => json)
+ .catch((e) => undefined);
+};
+
+export default async function TermsAndConditions() {
+ const termsAndConditions = await getTermsAndConditions();
+
+ return (
+
+
Terms And Conditions
+
+
+ );
+}
diff --git a/src/app/(consumer)/usermgmt/page.tsx b/src/app/(consumer)/usermgmt/page.tsx
new file mode 100644
index 0000000..3b9dead
--- /dev/null
+++ b/src/app/(consumer)/usermgmt/page.tsx
@@ -0,0 +1,30 @@
+import { redirect } from "next/navigation";
+import VerifyEmailPage from "./verify-email-page";
+import ResetPasswordPage from "./reset-password-page";
+
+export default function UserManagement({
+ searchParams
+}: {
+ searchParams: { [key: string]: string | string[] | undefined };
+}) {
+ const mode = searchParams["mode"];
+ const oobCode = searchParams["oobCode"];
+
+ if (typeof mode !== "string") {
+ redirect("/");
+ }
+
+ if (!mode.match("resetPassword|verifyEmail")) {
+ redirect("/");
+ }
+
+ if (typeof oobCode !== "string" || oobCode.trim().length === 0) {
+ redirect("/");
+ }
+
+ if (mode === "resetPassword") {
+ return ;
+ }
+
+ return ;
+}
\ No newline at end of file
diff --git a/src/app/(consumer)/usermgmt/reset-password-page.tsx b/src/app/(consumer)/usermgmt/reset-password-page.tsx
new file mode 100644
index 0000000..3e23289
--- /dev/null
+++ b/src/app/(consumer)/usermgmt/reset-password-page.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { PasswordInput } from "@/components/forms";
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Loading } from "@/components/ui/loading";
+import { useToast } from "@/components/ui/use-toast";
+import { firebaseAuth } from "@/lib/firebase.config";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { applyActionCode, confirmPasswordReset } from "firebase/auth";
+import { LoaderCircle } from "lucide-react";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+const schema = z
+ .object({
+ newPassword: z.string().min(8, {
+ message: "Password must be at least 8 characters",
+ }),
+ confirmPassword: z.string(),
+ })
+ .refine((data) => data.newPassword === data.confirmPassword, {
+ message: "Passwords do not match",
+ path: ["confirmPassword"],
+ });
+
+type ResetPasswordForm = z.infer;
+
+export default function ResetPasswordPage({ oobCode }: { oobCode: string }) {
+ const [verified, setVerified] = useState(false);
+ const [error, setError] = useState();
+ const initRef = useRef(false);
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const {
+ register,
+ formState: { errors, isSubmitting },
+ handleSubmit,
+ } = useForm({
+ resolver: zodResolver(schema),
+ });
+
+ const verifyCode = useCallback(async (code: string) => {
+ try {
+ setError(undefined);
+ const auth = firebaseAuth;
+ await applyActionCode(auth, code);
+ setVerified(true);
+ } catch (error) {
+ setError(parseErrorResponse(error));
+ }
+ }, []);
+
+ useEffect(() => {
+ if (initRef.current) {
+ return;
+ }
+ initRef.current = true;
+ verifyCode(oobCode);
+ }, [oobCode, verifyCode]);
+
+ const resetPassword = async (values: ResetPasswordForm) => {
+ try {
+ const auth = firebaseAuth;
+ await confirmPasswordReset(auth, oobCode, values.newPassword);
+ toast({
+ title: "Success",
+ description: "Reset password success. Please login again.",
+ variant: "success",
+ });
+ router.push("/login");
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ }
+ };
+
+ const content = () => {
+ if (error) {
+ return {error} ;
+ }
+
+ if (!verified) {
+ return ;
+ }
+
+ return (
+
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/(consumer)/usermgmt/verify-email-page.tsx b/src/app/(consumer)/usermgmt/verify-email-page.tsx
new file mode 100644
index 0000000..ad2d6a7
--- /dev/null
+++ b/src/app/(consumer)/usermgmt/verify-email-page.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { Alert } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Loading } from "@/components/ui/loading";
+import { firebaseAuth } from "@/lib/firebase.config";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { applyActionCode } from "firebase/auth";
+import { Check } from "lucide-react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+
+export default function VerifyEmailPage({ oobCode }: { oobCode: string }) {
+ const [verified, setVerified] = useState(false);
+ const [error, setError] = useState();
+ const router = useRouter();
+
+ const initRef = useRef(false);
+
+ const verifyCode = useCallback(async (code: string) => {
+ try {
+ setError(undefined);
+ const auth = firebaseAuth;
+ await applyActionCode(auth, code);
+ setVerified(true);
+ const refreshResponse = await fetch("/api/auth/refresh", {
+ method: "POST",
+ });
+ router.refresh();
+ } catch (error) {
+ setError(parseErrorResponse(error));
+ }
+ }, [router]);
+
+ useEffect(() => {
+ if (initRef.current) {
+ return;
+ }
+ initRef.current = true;
+ verifyCode(oobCode);
+ }, [oobCode, verifyCode]);
+
+ const content = () => {
+ if (error) {
+ return {error} ;
+ }
+
+ if (!verified) {
+ return ;
+ }
+
+ return (
+
+
+
+
Your email has been verified
+
+
+
+ Browse courses
+
+
+ Go to profile
+
+
+
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/(consumer)/verify-email/page.tsx b/src/app/(consumer)/verify-email/page.tsx
new file mode 100644
index 0000000..91929a3
--- /dev/null
+++ b/src/app/(consumer)/verify-email/page.tsx
@@ -0,0 +1,51 @@
+import { Button } from "@/components/ui/button";
+import { getSession } from "@/lib/auth";
+import * as jose from "jose";
+import { Check } from "lucide-react";
+import Link from "next/link";
+import VerifyEmailPage from "./verify-email-page";
+import { Card, CardContent } from "@/components/ui/card";
+
+export default async function VerifyEmail() {
+ const session = await getSession();
+
+ const { accessToken } = session;
+
+ const payload = jose.decodeJwt(accessToken);
+ const emailVerified = payload["email_verified"];
+
+ if (emailVerified) {
+ return (
+
+
+
+
+
+
+
+
+ Your email has been verified
+
+
+
+
+ Browse courses
+
+
+ Go to profile
+
+
+
+
+
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/app/(consumer)/verify-email/verify-email-page.tsx b/src/app/(consumer)/verify-email/verify-email-page.tsx
new file mode 100644
index 0000000..c007708
--- /dev/null
+++ b/src/app/(consumer)/verify-email/verify-email-page.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { useToast } from "@/components/ui/use-toast";
+import { sendVerificationEmail } from "@/lib/actions";
+import Image from "next/image";
+import { useEffect, useState } from "react";
+
+export default function VerifyEmailPage() {
+ const [resend, setResend] = useState(true);
+ const [timer, setTimer] = useState(0);
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (resend) {
+ return;
+ }
+
+ setTimer(30);
+
+ const interval = setInterval(() => {
+ setTimer((old) => {
+ if (old > 1) {
+ return old - 1;
+ }
+
+ setResend(true);
+
+ return 0;
+ });
+ }, 1000);
+
+ return () => {
+ clearInterval(interval);
+ };
+ }, [resend]);
+
+ return (
+
+
+
+
+
Verify your email
+
+ Check your email & click the link to verify your email.
+
+
+
+
+
+ {
+ try {
+ setResend(false);
+ await sendVerificationEmail();
+ } catch (error: any) {
+ toast({
+ title: "Error",
+ description: error.message,
+ variant: "destructive",
+ });
+ }
+ }}
+ >
+ Resend confirmation email
+ {timer > 0 && ({timer}) }
+
+
+
+ Didn't get confirmation email?
+
+
+
+
+
+ );
+}
diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts
new file mode 100644
index 0000000..eb7d5bb
--- /dev/null
+++ b/src/app/api/auth/refresh/route.ts
@@ -0,0 +1,65 @@
+import { cookies } from "next/headers";
+import { NextRequest } from "next/server";
+
+export async function POST(request: NextRequest) {
+ const cookieStore = cookies();
+ const refreshToken = cookieStore.get("refresh_token")?.value;
+ try {
+ if (!refreshToken) {
+ throw "Refresh token not found";
+ }
+
+ const url = `https://securetoken.googleapis.com/v1/token?key=${process.env.NEXT_PUBLIC_FIREBASE_API_KEY}`;
+ const body = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ });
+
+ const resp = await fetch(url, {
+ method: "POST",
+ body: body,
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ });
+
+ const json = await resp.json();
+
+ if (!resp.ok) {
+ cookieStore.delete("access_token");
+ cookieStore.delete("refresh_token");
+ return Response.json(json, { status: 401 });
+ }
+
+ const result = {
+ accessToken: json["id_token"],
+ refreshToken: json["refresh_token"],
+ };
+
+ cookieStore.set({
+ name: "access_token",
+ value: result.accessToken,
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 2592000,
+ });
+
+ cookieStore.set({
+ name: "refresh_token",
+ value: result.refreshToken,
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ maxAge: 2592000,
+ });
+
+ return Response.json(result);
+ } catch (error) {
+ cookieStore.delete("access_token");
+ cookieStore.delete("refresh_token");
+ return new Response(null, {
+ status: 401,
+ });
+ }
+}
diff --git a/src/app/error.tsx b/src/app/error.tsx
new file mode 100644
index 0000000..9422446
--- /dev/null
+++ b/src/app/error.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { useEffect } from "react";
+
+export default function Error({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+
+ useEffect(() => {
+ console.error(error);
+ }, [error]);
+ return (
+
+
Something went wrong!
+ {
+ window.location.reload();
+ }}
+ >
+ Try again
+
+
+ );
+}
diff --git a/src/app/favicon.ico b/src/app/favicon.ico
new file mode 100644
index 0000000..718d6fe
Binary files /dev/null and b/src/app/favicon.ico differ
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..46cb061
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,262 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@import "swiper/css";
+@import "swiper/css/navigation";
+@import "swiper/css/pagination";
+@import "swiper/css/zoom";
+@import "../styles/prosemirror.css";
+@import 'katex/dist/katex.min.css';
+
+/* @layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+} */
+
+@layer base {
+ @font-face {
+ font-family: Inter;
+ font-weight: 300;
+ src: url(../../public/fonts/Inter-Light.ttf) format("truetype");
+ }
+ @font-face {
+ font-family: Inter;
+ font-weight: 400;
+ src: url(../../public/fonts/Inter-Regular.ttf) format("truetype");
+ }
+ @font-face {
+ font-family: Inter;
+ font-weight: 500;
+ src: url(../../public/fonts/Inter-Medium.ttf) format("truetype");
+ }
+ @font-face {
+ font-family: Inter;
+ font-weight: 600;
+ src: url(../../public/fonts/Inter-SemiBold.ttf) format("truetype");
+ }
+ @font-face {
+ font-family: Inter;
+ font-weight: 700;
+ src: url(../../public/fonts/Inter-Bold.ttf) format("truetype");
+ }
+
+ :root {
+ --novel-highlight-default: #ffffff;
+ --novel-highlight-purple: #f6f3f8;
+ --novel-highlight-red: #fdebeb;
+ --novel-highlight-yellow: #fbf4a2;
+ --novel-highlight-blue: #c1ecf9;
+ --novel-highlight-green: #acf79f;
+ --novel-highlight-orange: #faebdd;
+ --novel-highlight-pink: #faf1f5;
+ --novel-highlight-gray: #f1f1ef;
+
+ --background: 0 0% 100%;
+ --foreground: 224 71.4% 4.1%;
+
+ --card: 0 0% 100%;
+ --card-foreground: 224 71.4% 4.1%;
+
+ --popover: 0 0% 100%;
+ --popover-foreground: 224 71.4% 4.1%;
+
+ --primary: 243 75% 59%;
+ --primary-foreground: 210 40% 98%;
+
+ --secondary: 37 95% 56%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --muted: 220 14.3% 95.9%;
+ --muted-foreground: 220 8.9% 46.1%;
+
+ --accent: 220 14.3% 95.9%;
+ --accent-foreground: 220.9 39.3% 11%;
+
+ --destructive: 359 100% 42%;
+ --destructive-foreground: 210 20% 98%;
+
+ --border: 220 13% 91%;
+ --input: 220 13% 91%;
+ --ring: 224 71.4% 4.1%;
+
+ --radius: 0.35rem;
+ }
+
+ .dark {
+ --novel-highlight-default: #000000;
+ --novel-highlight-purple: #3f2c4b;
+ --novel-highlight-red: #5c1a1a;
+ --novel-highlight-yellow: #5c4b1a;
+ --novel-highlight-blue: #1a3d5c;
+ --novel-highlight-green: #1a5c20;
+ --novel-highlight-orange: #5c3a1a;
+ --novel-highlight-pink: #5c1a3a;
+ --novel-highlight-gray: #3a3a3a;
+
+ --background: 224 71.4% 4.1%;
+ --foreground: 210 20% 98%;
+
+ --card: 224 71.4% 4.1%;
+ --card-foreground: 210 20% 98%;
+
+ --popover: 224 71.4% 4.1%;
+ --popover-foreground: 210 20% 98%;
+
+ --primary: 239 84% 67%;
+ --primary-foreground: 0 0% 93%;
+
+ --secondary: 37 95% 56%;
+ --secondary-foreground: 222.2 47.4% 11.2%;
+
+ --muted: 215 27.9% 16.9%;
+ --muted-foreground: 217.9 10.6% 64.9%;
+
+ --accent: 215 27.9% 16.9%;
+ --accent-foreground: 210 20% 98%;
+
+ --destructive: 359 100% 63%;
+ --destructive-foreground: 222.2 47.4% 11.2%;
+
+ --border: 215 27.9% 16.9%;
+ --input: 215 27.9% 16.9%;
+ --ring: 216 12.2% 83.9%;
+ }
+
+ h1 {
+ @apply text-4xl font-semibold;
+ }
+
+ h2 {
+ @apply text-3xl font-semibold;
+ }
+
+ h3 {
+ @apply text-2xl font-semibold;
+ }
+
+ h4 {
+ @apply text-xl font-semibold;
+ }
+
+ h5 {
+ @apply text-lg font-semibold;
+ }
+
+ h6 {
+ @apply text-base font-semibold;
+ }
+
+ /* a {
+ @apply text-anchor;
+ } */
+}
+
+@layer components {
+ .default-input {
+ @apply border-border focus:border-primary focus:ring-0 bg-background disabled:bg-muted-foreground/20 disabled:cursor-not-allowed;
+ }
+
+ .valid-input {
+ @apply border-success focus:border-success focus:ring-[4px] focus:ring-success/30 bg-background;
+ }
+
+ .invalid-input {
+ @apply border-destructive focus:border-destructive focus:ring-[4px] focus:ring-destructive/30 bg-background;
+ }
+}
+
+/* html,
+body {
+ margin: 0;
+ background-color: #ff0000;
+ font-family: Inter, Noto Sans Myanmar UI, -apple-system, BlinkMacSystemFont,
+ Roboto, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans,
+ Helvetica Neue, sans-serif !important;
+} */
+
+/* svg {
+ display: block;
+} */
+
+/* .lucide {
+ stroke-width: 2px;
+} */
+
+html body[data-scroll-locked] {
+ --removed-body-scroll-bar-size: 0px !important;
+ margin-right: 0px !important;
+ overflow-y: auto !important;
+}
+
+/* Hide scrollbar for Chrome, Safari and Opera */
+.scrollbar-none::-webkit-scrollbar {
+ display: none;
+}
+
+/* Hide scrollbar for IE, Edge and Firefox */
+.scrollbar-none {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+}
+
+/* width */
+.scrollbar-custom::-webkit-scrollbar {
+ width: 4px;
+ height: 4px;
+}
+
+/* Track */
+.scrollbar-custom::-webkit-scrollbar-track {
+ background: #f2f2f2;
+ border-radius: 3px;
+}
+
+/* Handle */
+.scrollbar-custom::-webkit-scrollbar-thumb {
+ background: #ccc;
+ border-radius: 3px;
+}
+
+/* Handle on hover */
+.scrollbar-custom::-webkit-scrollbar-thumb:hover {
+ background: #aaa;
+}
+
+/* Swiper */
+.swiper-pagination-bullet {
+ background-color: hsl(var(--primary));
+ width: 10px;
+ height: 10px;
+ opacity: 0.3;
+}
+.swiper-pagination-bullet-active {
+ background-color: hsl(var(--primary));
+ opacity: 1;
+}
+
+.swiper-button-next,
+.swiper-button-prev {
+ --swiper-navigation-color: hsl(var(--primary));
+}
+
+/* Tinymce */
+.tox-pop .tox-pop__dialog .tox-toolbar {
+ padding: 0 !important;
+}
+
+.tox .tox-collection__item-label h1, h2, h3, h4, h5, h6, p, pre {
+ background-color: transparent !important;
+}
+
+.tox .tox-edit-area__iframe {
+ border-radius: 0px;
+}
+
+.mce-edit-focus {
+ outline: none;
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..996efdb
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,61 @@
+import "./globals.css";
+import "../styles/tiptap.scss";
+
+import { AuthenticationContextProvider } from "@/components/authentication-context-porvider";
+import Providers from "@/components/providers";
+import { API_URL_LOCAL } from "@/lib/constants";
+import { User } from "@/lib/models";
+import type { Metadata, Viewport } from "next";
+import { revalidatePath } from "next/cache";
+import { cookies, headers } from "next/headers";
+
+export const viewport: Viewport = {
+ themeColor: {
+ media: "(prefers-color-scheme: light)",
+ color: "#ffffff",
+ },
+ initialScale: 1,
+ width: "device-width",
+};
+
+export const metadata: Metadata = {
+ title: "Hope E-Learning",
+ description: process.env.NEXT_PUBLIC_APP_DESC,
+};
+
+const getUser = async () => {
+ const url = `${API_URL_LOCAL}/profile`;
+ const resp = await fetch(url, {
+ headers: {
+ Cookie: cookies().toString(),
+ },
+ next: { revalidate: 10 },
+ });
+
+ return resp.ok ? ((await resp.json()) as User) : null;
+};
+
+export default async function RootLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ const user = await getUser();
+
+ if (headers().get("revalidate") === "true") {
+ revalidatePath("/", "layout");
+ }
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..f76537a
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,15 @@
+import Link from "next/link";
+
+export default function NotFound() {
+ return (
+
+
+
404
+
Could not find requested resource
+
+ Return Home
+
+
+
+ );
+}
diff --git a/src/app/robots.txt b/src/app/robots.txt
new file mode 100644
index 0000000..57dd792
--- /dev/null
+++ b/src/app/robots.txt
@@ -0,0 +1,7 @@
+User-Agent: *
+Allow: /
+Disallow: /profile/
+Disallow: /admin/
+Disallow: /learn/
+Disallow: /usermgmt
+Disallow: /verify-email
\ No newline at end of file
diff --git a/src/components/authentication-context-porvider.tsx b/src/components/authentication-context-porvider.tsx
new file mode 100644
index 0000000..ed8c100
--- /dev/null
+++ b/src/components/authentication-context-porvider.tsx
@@ -0,0 +1,33 @@
+"use client";
+import { User } from "@/lib/models";
+import { ReactNode, createContext, useEffect, useState } from "react";
+
+interface AuthenticationContextType {
+ user?: User | null | undefined;
+}
+
+const AuthenticationContext = createContext({
+ user: undefined,
+});
+
+const AuthenticationContextProvider = ({
+ user,
+ children,
+}: {
+ user?: User | null | undefined;
+ children: ReactNode;
+}) => {
+ const [authState, setAuthState] = useState({});
+
+ useEffect(() => {
+ setAuthState({ user: user });
+ }, [user]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export { AuthenticationContext, AuthenticationContextProvider };
diff --git a/src/components/blog/blog-grid-item.tsx b/src/components/blog/blog-grid-item.tsx
new file mode 100644
index 0000000..b46bb01
--- /dev/null
+++ b/src/components/blog/blog-grid-item.tsx
@@ -0,0 +1,56 @@
+import { Post } from "@/lib/models";
+import {
+ formatAbbreviate,
+ formatRelativeTimestamp,
+ wordPerMinute,
+} from "@/lib/utils";
+import Image from "next/image";
+import Link from "next/link";
+import { Card, CardContent, CardFooter } from "../ui/card";
+import { Separator } from "../ui/separator";
+
+export function BlogGridItem({ data }: { data: Post }) {
+ return (
+
+
+
+
+
+
+
+ {/* */}
+
+
+ {data.title ?? "(Untitled)"}
+
+
+
+ {formatRelativeTimestamp(data.publishedAt)}
+
+
+
{data.excerpt}
+
+
+
+
+
+ {wordPerMinute(data.wordCount)} min read
+
+
+
+ {formatAbbreviate(BigInt(data.meta?.viewCount ?? 0))} views
+
+
+
+ );
+}
diff --git a/src/components/blog/index.ts b/src/components/blog/index.ts
new file mode 100644
index 0000000..7eade7e
--- /dev/null
+++ b/src/components/blog/index.ts
@@ -0,0 +1 @@
+export * from "./blog-grid-item";
diff --git a/src/components/course/bookmark-course-grid-item.tsx b/src/components/course/bookmark-course-grid-item.tsx
new file mode 100644
index 0000000..9f2fe24
--- /dev/null
+++ b/src/components/course/bookmark-course-grid-item.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { removeBookmark } from "@/lib/actions";
+import { Course } from "@/lib/models";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { uppercaseFirstChar } from "@/lib/utils";
+import { LoaderCircle, Trash2 } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useState } from "react";
+import { Button } from "../ui/button";
+import { Card, CardContent, CardFooter } from "../ui/card";
+import Rating from "../ui/rating";
+import { Separator } from "../ui/separator";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../ui/tooltip";
+import { useToast } from "../ui/use-toast";
+
+export function BookmarkCourseGridItem({ data }: { data: Course }) {
+ const [isLoading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ const handleRemove = async () => {
+ try {
+ setLoading(true);
+ await removeBookmark(data.id, "/profile/bookmarks");
+ toast({
+ title: "Success",
+ description: "Removed bookmark",
+ variant: "success",
+ });
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+ Remove bookmark
+
+
+
+
+
+ {data.title}
+
+
+ {uppercaseFirstChar(data.level)}
+
+
+
+
+
+
+
+
+
+ {uppercaseFirstChar(data.access)}
+
+
+
+ );
+}
diff --git a/src/components/course/course-grid-item.tsx b/src/components/course/course-grid-item.tsx
new file mode 100644
index 0000000..1b5b8d7
--- /dev/null
+++ b/src/components/course/course-grid-item.tsx
@@ -0,0 +1,51 @@
+import { Course } from "@/lib/models";
+import { formatAbbreviate, uppercaseFirstChar } from "@/lib/utils";
+import Image from "next/image";
+import Link from "next/link";
+import { Card, CardContent, CardFooter } from "../ui/card";
+import Rating from "../ui/rating";
+import { Separator } from "../ui/separator";
+
+export function CourseGridItem({ data }: { data: Course }) {
+ return (
+
+
+
+
+
+
+
+
+
+ {data.title}
+
+
+
+ {formatAbbreviate(BigInt(data.meta?.enrolledCount ?? 0))} enrolled
+
+
•
+
{uppercaseFirstChar(data.level)}
+
+
+
+
+
+
+
+
+ {uppercaseFirstChar(data.access)}
+
+
+
+ );
+}
diff --git a/src/components/course/enrolled-course-grid-item.tsx b/src/components/course/enrolled-course-grid-item.tsx
new file mode 100644
index 0000000..7145629
--- /dev/null
+++ b/src/components/course/enrolled-course-grid-item.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import { removeEnrollment } from "@/lib/actions";
+import { EnrolledCourse } from "@/lib/models";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { uppercaseFirstChar } from "@/lib/utils";
+import { LoaderCircle, Trash2 } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { useState } from "react";
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "../ui/alert-dialog";
+import { Button } from "../ui/button";
+import { Card, CardContent, CardFooter } from "../ui/card";
+import { Progress } from "../ui/progress";
+import { Separator } from "../ui/separator";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../ui/tooltip";
+import { useToast } from "../ui/use-toast";
+
+export function EnrolledCourseGridItem({ data }: { data: EnrolledCourse }) {
+ const [isLoading, setLoading] = useState(false);
+ const [isAlertOpen, setAlertOpen] = useState(false);
+ const { toast } = useToast();
+
+ const handleRemove = async () => {
+ try {
+ setLoading(true);
+ await removeEnrollment(data.course.id, "/profile/learnings");
+ toast({
+ title: "Success",
+ description: "Removed enrollment",
+ variant: "success",
+ });
+ setAlertOpen(false);
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setAlertOpen(true)}
+ className="shadow-lg absolute top-2 right-2"
+ >
+
+
+
+
+ Remove enrollment
+
+
+
+ Confirm remove
+
+ Are you sure to unenroll: “{data.course.title}“?
+
+
+
+
+ Cancel
+
+
+ {isLoading && (
+
+ )}
+ Proceed
+
+
+
+
+
+
+
+
+ {data.course.title}
+
+
+
+ {uppercaseFirstChar(data.course.access)}
+
+
•
+
+ {uppercaseFirstChar(data.course.level)}
+
+
+
+
+
+
+ {data.resumeLesson ? (
+
+ Resume course
+
+ ) : (
+ Resume course
+ )}
+
+
+
+
+
+
+ {data.progress}%
+
+
+ );
+}
diff --git a/src/components/course/index.ts b/src/components/course/index.ts
new file mode 100644
index 0000000..6728b37
--- /dev/null
+++ b/src/components/course/index.ts
@@ -0,0 +1,3 @@
+export * from "./bookmark-course-grid-item";
+export * from "./course-grid-item";
+export * from "./enrolled-course-grid-item";
diff --git a/src/components/editor/content-renderer.tsx b/src/components/editor/content-renderer.tsx
new file mode 100644
index 0000000..47632d3
--- /dev/null
+++ b/src/components/editor/content-renderer.tsx
@@ -0,0 +1,44 @@
+"use client";
+import { EditorContent, EditorRoot, JSONContent } from "novel";
+import { defaultExtensions } from "./extensions";
+
+export function ContentRenderer({ lexical }: { lexical?: string | JSON }) {
+ const content = () => {
+ if (typeof lexical === "string") {
+ return JSON.parse(lexical) as JSONContent;
+ }
+
+ if (typeof lexical === "object") {
+ return lexical as JSONContent;
+ }
+
+ return undefined;
+ };
+
+ return (
+
+ false,
+ attributes: {
+ class:
+ "prose dark:prose-invert max-w-none prose-pre:rounded",
+ },
+ }}
+ >
+
+
+
+ );
+
+ // return (
+ //
+ // );
+}
diff --git a/src/components/editor/extensions.ts b/src/components/editor/extensions.ts
new file mode 100644
index 0000000..e6b0a2e
--- /dev/null
+++ b/src/components/editor/extensions.ts
@@ -0,0 +1,220 @@
+import { cn } from "@/lib/utils";
+import { mergeAttributes } from "@tiptap/core";
+import { CharacterCount } from "@tiptap/extension-character-count";
+import Heading from "@tiptap/extension-heading";
+import Table, { createColGroup } from "@tiptap/extension-table";
+import TableCell from "@tiptap/extension-table-cell";
+import TableHeader from "@tiptap/extension-table-header";
+import TableRow from "@tiptap/extension-table-row";
+import TextAlign from "@tiptap/extension-text-align";
+import { DOMOutputSpec } from "@tiptap/pm/model";
+import {
+ AIHighlight,
+ GlobalDragHandle,
+ Placeholder,
+ StarterKit,
+ TaskItem,
+ TaskList,
+ TiptapImage,
+ TiptapLink,
+ Youtube,
+} from "novel/extensions";
+import { UploadImagesPlugin } from "novel/plugins";
+import { CustomCodeBlock } from "./extensions/codeblock";
+import { Mathematics } from "./extensions/mathematics";
+
+// You can overwrite the placeholder with your own configuration
+const aiHighlight = AIHighlight;
+
+const placeholder = Placeholder.configure({
+ includeChildren: false,
+});
+
+const heading = Heading.extend({
+ renderHTML({ node, HTMLAttributes }) {
+ const hasLevel = this.options.levels.includes(node.attrs.level);
+ const level = hasLevel ? node.attrs.level : this.options.levels[0];
+
+ if (!this.editor?.isEditable && node.textContent) {
+ return [
+ `h${level}`,
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ id: node.textContent.replaceAll(/\s+/g, "-").toLowerCase(),
+ }),
+ 0,
+ ];
+ }
+ return [
+ `h${level}`,
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
+ 0,
+ ];
+ },
+});
+
+const tiptapLink = TiptapLink.configure({
+ HTMLAttributes: {
+ class: cn(
+ "!text-foreground underline underline-offset-[3px] transition-colors cursor-pointer"
+ ),
+ },
+});
+
+const taskList = TaskList.configure({
+ HTMLAttributes: {
+ class: cn("not-prose pl-2"),
+ },
+});
+
+const taskItem = TaskItem.configure({
+ HTMLAttributes: {
+ class: cn("flex items-start my-4"),
+ },
+ nested: true,
+});
+
+const textAlign = TextAlign.configure({
+ types: ["heading", "paragraph", "math"],
+});
+
+// const horizontalRule = HorizontalRule.configure({
+// HTMLAttributes: {
+// class: cn("mt-10 mb-10 border-t"),
+// },
+// });
+
+const tiptapImage = TiptapImage.extend({
+ addProseMirrorPlugins() {
+ return [
+ UploadImagesPlugin({
+ imageClass: cn("opacity-40 rounded border"),
+ }),
+ ];
+ },
+}).configure({
+ allowBase64: false,
+ HTMLAttributes: {
+ class: cn("rounded border mx-auto"),
+ },
+});
+
+// const updatedImage = UpdatedImage.configure({
+// HTMLAttributes: {
+// class: cn("rounded border"),
+// },
+// });
+
+const youtube = Youtube.configure({
+ HTMLAttributes: {
+ class: cn("rounded border border-muted"),
+ },
+ inline: false,
+});
+
+const TiptapTable = Table.extend({
+ renderHTML({ node, HTMLAttributes }) {
+ const { colgroup, tableWidth, tableMinWidth } = createColGroup(
+ node,
+ this.options.cellMinWidth
+ );
+
+ const table: DOMOutputSpec = [
+ "div",
+ {
+ class: "table-wrapper overflow-y-auto my-[2em] not-draggable",
+ },
+ [
+ "table",
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
+ style: tableWidth
+ ? `width: ${tableWidth}`
+ : `minWidth: ${tableMinWidth}`,
+ }),
+ colgroup,
+ ["tbody", 0],
+ ],
+ ];
+
+ return table;
+ },
+}).configure({
+ HTMLAttributes: {
+ class: cn("not-prose table-auto border-collapse w-full not-draggable"),
+ },
+ lastColumnResizable: false,
+ allowTableNodeSelection: true,
+});
+
+const starterKit = StarterKit.configure({
+ bulletList: {
+ HTMLAttributes: {
+ class: cn("list-disc list-outside leading-3 -mt-2"),
+ },
+ },
+ orderedList: {
+ HTMLAttributes: {
+ class: cn("list-decimal list-outside leading-3 -mt-2"),
+ },
+ },
+ listItem: {
+ HTMLAttributes: {
+ class: cn("leading-normal -mb-2"),
+ },
+ },
+ blockquote: {
+ HTMLAttributes: {
+ class: cn("border-l-4 border-gray-600"),
+ },
+ },
+ codeBlock: false,
+ code: {
+ HTMLAttributes: {
+ class: cn(
+ "rounded-lg bg-muted text-red-700 dark:bg-muted/90 dark:text-red-400 px-1.5 py-1 font-mono font-medium before:content-none after:content-none"
+ ),
+ spellcheck: "false",
+ },
+ },
+ horizontalRule: {
+ HTMLAttributes: {
+ class: cn("my-4 bg-border border-border"),
+ },
+ },
+ dropcursor: {
+ color: "#DBEAFE",
+ width: 4,
+ },
+ // gapcursor: false,
+ heading: false,
+});
+
+export const defaultExtensions = [
+ starterKit,
+ placeholder,
+ tiptapLink,
+ tiptapImage,
+ textAlign,
+ heading,
+ taskList,
+ taskItem,
+ aiHighlight,
+ CustomCodeBlock,
+ youtube,
+ CharacterCount,
+ GlobalDragHandle,
+ Mathematics,
+ TiptapTable,
+ TableHeader.configure({
+ HTMLAttributes: {
+ class: cn(
+ "bg-muted border border-default p-2 text-start min-w-[150px] font-semibold"
+ ),
+ },
+ }),
+ TableRow,
+ TableCell.configure({
+ HTMLAttributes: {
+ class: cn("border border-default p-2 min-w-[150px] align-middle"),
+ },
+ }),
+];
diff --git a/src/components/editor/extensions/codeblock.ts b/src/components/editor/extensions/codeblock.ts
new file mode 100644
index 0000000..30ed52a
--- /dev/null
+++ b/src/components/editor/extensions/codeblock.ts
@@ -0,0 +1,136 @@
+import { cn } from "@/lib/utils";
+import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
+import { common, createLowlight } from "lowlight";
+
+const lowlight = createLowlight(common);
+
+export const CustomCodeBlock = CodeBlockLowlight.extend({
+ addKeyboardShortcuts() {
+ return {
+ Tab: ({ editor }) => {
+ if (editor.isActive("codeBlock")) {
+ editor
+ .chain()
+ .command(({ tr }) => {
+ tr.insertText(" ");
+ return true;
+ })
+ .run();
+ }
+ return true;
+ },
+ };
+ },
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ HTMLAttributes: {
+ class: cn(
+ "rounded !bg-gray-800 dark:!bg-gray-900 text-gray-200 border p-5 font-mono font-medium",
+ ),
+ spellcheck: false,
+ },
+ defaultLanguage: "plaintext",
+ lowlight: lowlight,
+ };
+ },
+ addNodeView() {
+ return ({ node, HTMLAttributes, getPos, editor }) => {
+ const language = node.attrs["language"] ?? this.options.defaultLanguage;
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "relative before:hidden";
+
+ const pre = document.createElement("pre");
+
+ if (editor.isEditable) {
+ const select = document.createElement("select");
+ select.className =
+ "absolute bg-gray-800/50 text-white/70 rounded top-4 right-4 px-2 py-1";
+
+ for (const l in common) {
+ const option = document.createElement("option");
+ const v = document.createAttribute("value");
+ const t = document.createTextNode(l);
+ v.value = l;
+ option.appendChild(t);
+ option.setAttributeNode(v);
+ select.appendChild(option);
+ option.selected = l === language;
+ }
+
+ select.addEventListener("change", (evt) => {
+ const { value } = evt.target as any;
+
+ if (editor.isEditable && typeof getPos === "function") {
+ editor
+ .chain()
+ .focus(undefined, { scrollIntoView: false })
+ .command(({ tr }) => {
+ const position = getPos();
+ const currentNode = tr.doc.nodeAt(position);
+
+ tr.setNodeMarkup(position, undefined, {
+ ...currentNode?.attrs,
+ language: value,
+ });
+
+ return true;
+ })
+ .run();
+ }
+ });
+ wrapper.append(select);
+ } else {
+ const button = document.createElement("button");
+ button.className =
+ "absolute text-sm rounded bg-gray-800/50 text-white/70 hover:text-white border border-white/70 hover:border-white top-3 right-3 px-2 py-0.5";
+ button.textContent = "Copy";
+ wrapper.append(button);
+
+ button.addEventListener("click", (evt) => {
+ navigator.clipboard.writeText(node.textContent);
+ const target = evt.target as any;
+ if (target) {
+ target.textContent = "Copied!";
+ }
+
+ setTimeout(() => {
+ if (target) {
+ target.textContent = "Copy";
+ }
+ }, 2000);
+ });
+ }
+
+ const code = document.createElement("code");
+ pre.append(code);
+
+ Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
+ pre.setAttribute(key, value);
+ });
+
+ code.classList.add(`${this.options.languageClassPrefix}${language}`);
+
+ wrapper.append(pre);
+
+ return {
+ dom: wrapper,
+ contentDOM: code,
+ update: (updatedNode) => {
+ if (updatedNode.type !== this.type) {
+ return false;
+ }
+
+ const newLang =
+ updatedNode.attrs["language"] ?? this.options.defaultLanguage;
+
+ code.removeAttribute("class");
+ code.classList.add(`${this.options.languageClassPrefix}${newLang}`);
+
+ return true;
+ },
+ };
+ };
+ },
+});
diff --git a/src/components/editor/extensions/embed.ts b/src/components/editor/extensions/embed.ts
new file mode 100644
index 0000000..ffef0b4
--- /dev/null
+++ b/src/components/editor/extensions/embed.ts
@@ -0,0 +1,73 @@
+import { cn } from "@/lib/utils";
+import { Extension } from "@tiptap/core";
+
+export interface IFrameOptions {
+ allowFullscreen: boolean;
+ HTMLAttributes: {
+ [key: string]: any;
+ };
+}
+
+declare module "@tiptap/core" {
+ interface Commands {
+ iframe: {
+ /**
+ * Add an iframe
+ */
+ setIframe: (options: { src: string }) => ReturnType;
+ };
+ }
+}
+
+export const Embed = Extension.create({
+ name: "iframe",
+ addOptions() {
+ return {
+ allowFullscreen: true,
+ HTMLAttributes: {
+ class: cn("aspect-w-16 aspect-h-9 w-full"),
+ },
+ };
+ },
+ addAttributes() {
+ return {
+ src: {
+ default: null,
+ },
+ frameborder: {
+ default: 0,
+ },
+ allowfullscreen: {
+ default: this.options.allowFullscreen,
+ parseHTML: () => this.options.allowFullscreen,
+ },
+ };
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: "iframe",
+ },
+ ];
+ },
+
+ // renderHTML({ HTMLAttributes }) {
+ // return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]
+ // },
+
+ // addCommands() {
+ // return {
+ // setIframe: (options: { src: string }) => ({ tr, dispatch }) => {
+ // const { selection } = tr
+ // const node = this.type.create(options)
+
+ // if (dispatch) {
+ // tr.replaceRangeWith(selection.from, selection.to, node)
+ // }
+
+ // return true
+ // },
+ // }
+ // },
+});
diff --git a/src/components/editor/extensions/mathematics.ts b/src/components/editor/extensions/mathematics.ts
new file mode 100644
index 0000000..8cdb049
--- /dev/null
+++ b/src/components/editor/extensions/mathematics.ts
@@ -0,0 +1,159 @@
+import { cn } from "@/lib/utils";
+import { Node, mergeAttributes } from "@tiptap/core";
+import { EditorState } from "@tiptap/pm/state";
+import katex, { KatexOptions } from "katex";
+
+export interface MathematicsOptions {
+ shouldRender: (state: EditorState, pos: number) => boolean;
+ katexOptions?: KatexOptions;
+ HTMLAttributes: Record;
+}
+
+declare module "@tiptap/core" {
+ interface Commands {
+ customCommand: {
+ setLatex: ({ latex }: { latex: string }) => ReturnType;
+ unsetLatex: () => ReturnType;
+ };
+ }
+}
+
+export const Mathematics = Node.create({
+ name: "math",
+ inline: true,
+ group: "inline",
+ atom: true,
+ selectable: true,
+ marks: "",
+
+ addAttributes() {
+ return {
+ latex: "",
+ };
+ },
+
+ addOptions() {
+ return {
+ shouldRender: (state, pos) => {
+ const $pos = state.doc.resolve(pos);
+
+ if (!$pos.parent.isTextblock) {
+ return false;
+ }
+
+ return $pos.parent.type.name !== "codeBlock";
+ },
+ katexOptions: {
+ throwOnError: false,
+ },
+ HTMLAttributes: {
+ class: cn("text-foreground rounded p-1"),
+ },
+ };
+ },
+
+ addCommands() {
+ return {
+ setLatex:
+ ({ latex }) =>
+ ({ chain, state }) => {
+ if (!latex) {
+ return false;
+ }
+ const { from, to, $anchor } = state.selection;
+
+ if (!this.options.shouldRender(state, $anchor.pos)) {
+ return false;
+ }
+
+ return chain()
+ .insertContentAt(
+ { from: from, to: to },
+ {
+ type: "math",
+ attrs: {
+ latex: latex,
+ },
+ }
+ )
+ .setTextSelection({ from: from, to: from + 1 })
+ .run();
+ },
+ unsetLatex:
+ () =>
+ ({ editor, state, chain }) => {
+ const latex = editor.getAttributes(this.name).latex;
+ if (typeof latex !== "string") {
+ return false;
+ }
+
+ const { from, to } = state.selection;
+
+ return chain()
+ .command(({ tr }) => {
+ tr.insertText(latex, from, to);
+ return true;
+ })
+ .setTextSelection({
+ from: from,
+ to: from + latex.length,
+ })
+ .run();
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: `span[data-type="${this.name}"]` }];
+ },
+
+ renderHTML({ node, HTMLAttributes }) {
+ const latex = node.attrs["latex"] ?? "";
+ return [
+ "span",
+ mergeAttributes(HTMLAttributes, {
+ "data-type": this.name,
+ }),
+ latex,
+ ];
+ },
+
+ renderText({ node }) {
+ return node.attrs["latex"] ?? "";
+ },
+
+ addNodeView() {
+ return ({ node, HTMLAttributes, getPos, editor }) => {
+ const dom = document.createElement("span");
+ const latex: string = node.attrs["latex"] ?? "";
+
+ Object.entries(this.options.HTMLAttributes).forEach(([key, value]) => {
+ dom.setAttribute(key, value);
+ });
+
+ Object.entries(HTMLAttributes).forEach(([key, value]) => {
+ dom.setAttribute(key, value);
+ });
+
+ if (editor.isEditable) {
+ dom.classList.add("hover:bg-accent", "cursor-pointer");
+ }
+
+ dom.addEventListener("click", (evt) => {
+ if (editor.isEditable && typeof getPos === "function") {
+ const pos = getPos();
+ const nodeSize = node.nodeSize;
+ editor.commands.setTextSelection({ from: pos, to: pos + nodeSize });
+ }
+ });
+
+ dom.contentEditable = "false";
+
+ dom.innerHTML = katex.renderToString(latex, this.options.katexOptions);
+
+ return {
+ dom: dom,
+ };
+ };
+ },
+});
diff --git a/src/components/editor/extensions/youtube.ts b/src/components/editor/extensions/youtube.ts
new file mode 100644
index 0000000..e28ec5f
--- /dev/null
+++ b/src/components/editor/extensions/youtube.ts
@@ -0,0 +1,223 @@
+import { cn } from "@/lib/utils";
+import { mergeAttributes } from "@tiptap/core";
+import Youtube from "@tiptap/extension-youtube";
+
+const YOUTUBE_REGEX =
+ /^(https?:\/\/)?(www\.|music\.)?(youtube\.com|youtu\.be|youtube-nocookie\.com)\/(?!channel\/)(?!@)(.+)?$/;
+const YOUTUBE_REGEX_GLOBAL =
+ /^(https?:\/\/)?(www\.|music\.)?(youtube\.com|youtu\.be)\/(?!channel\/)(?!@)(.+)?$/g;
+
+const isValidYoutubeUrl = (url: string) => {
+ return url.match(YOUTUBE_REGEX);
+};
+
+const getYoutubeEmbedUrl = (nocookie?: boolean) => {
+ return nocookie
+ ? "https://www.youtube-nocookie.com/embed/"
+ : "https://www.youtube.com/embed/";
+};
+
+interface GetEmbedUrlOptions {
+ url: string;
+ allowFullscreen?: boolean;
+ autoplay?: boolean;
+ ccLanguage?: string;
+ ccLoadPolicy?: boolean;
+ controls?: boolean;
+ disableKBcontrols?: boolean;
+ enableIFrameApi?: boolean;
+ endTime?: number;
+ interfaceLanguage?: string;
+ ivLoadPolicy?: number;
+ loop?: boolean;
+ modestBranding?: boolean;
+ nocookie?: boolean;
+ origin?: string;
+ playlist?: string;
+ progressBarColor?: string;
+ startAt?: number;
+}
+
+const getEmbedUrlFromYoutubeUrl = (options: GetEmbedUrlOptions) => {
+ const {
+ url,
+ allowFullscreen,
+ autoplay,
+ ccLanguage,
+ ccLoadPolicy,
+ controls,
+ disableKBcontrols,
+ enableIFrameApi,
+ endTime,
+ interfaceLanguage,
+ ivLoadPolicy,
+ loop,
+ modestBranding,
+ nocookie,
+ origin,
+ playlist,
+ progressBarColor,
+ startAt,
+ } = options;
+
+ if (!isValidYoutubeUrl(url)) {
+ return null;
+ }
+
+ // if is already an embed url, return it
+ if (url.includes("/embed/")) {
+ return url;
+ }
+
+ // if is a youtu.be url, get the id after the /
+ if (url.includes("youtu.be")) {
+ const id = url.split("/").pop();
+
+ if (!id) {
+ return null;
+ }
+ return `${getYoutubeEmbedUrl(nocookie)}${id}`;
+ }
+
+ const videoIdRegex = /(?:v=|shorts\/)([-\w]+)/gm;
+ const matches = videoIdRegex.exec(url);
+
+ if (!matches || !matches[1]) {
+ return null;
+ }
+
+ let outputUrl = `${getYoutubeEmbedUrl(nocookie)}${matches[1]}`;
+
+ const params = [];
+
+ if (allowFullscreen === false) {
+ params.push("fs=0");
+ }
+
+ if (autoplay) {
+ params.push("autoplay=1");
+ }
+
+ if (ccLanguage) {
+ params.push(`cc_lang_pref=${ccLanguage}`);
+ }
+
+ if (ccLoadPolicy) {
+ params.push("cc_load_policy=1");
+ }
+
+ if (!controls) {
+ params.push("controls=0");
+ }
+
+ if (disableKBcontrols) {
+ params.push("disablekb=1");
+ }
+
+ if (enableIFrameApi) {
+ params.push("enablejsapi=1");
+ }
+
+ if (endTime) {
+ params.push(`end=${endTime}`);
+ }
+
+ if (interfaceLanguage) {
+ params.push(`hl=${interfaceLanguage}`);
+ }
+
+ if (ivLoadPolicy) {
+ params.push(`iv_load_policy=${ivLoadPolicy}`);
+ }
+
+ if (loop) {
+ params.push("loop=1");
+ }
+
+ if (modestBranding) {
+ params.push("modestbranding=1");
+ }
+
+ if (origin) {
+ params.push(`origin=${origin}`);
+ }
+
+ if (playlist) {
+ params.push(`playlist=${playlist}`);
+ }
+
+ if (startAt) {
+ params.push(`start=${startAt}`);
+ }
+
+ if (progressBarColor) {
+ params.push(`color=${progressBarColor}`);
+ }
+
+ if (params.length) {
+ outputUrl += `?${params.join("&")}`;
+ }
+
+ return outputUrl;
+};
+
+export const CustomYoutube = Youtube.extend({
+ inline: false,
+ renderHTML({ HTMLAttributes }) {
+ const embedUrl = getEmbedUrlFromYoutubeUrl({
+ url: HTMLAttributes.src,
+ allowFullscreen: this.options.allowFullscreen,
+ autoplay: this.options.autoplay,
+ ccLanguage: this.options.ccLanguage,
+ ccLoadPolicy: this.options.ccLoadPolicy,
+ controls: this.options.controls,
+ disableKBcontrols: this.options.disableKBcontrols,
+ enableIFrameApi: this.options.enableIFrameApi,
+ endTime: this.options.endTime,
+ interfaceLanguage: this.options.interfaceLanguage,
+ ivLoadPolicy: this.options.ivLoadPolicy,
+ loop: this.options.loop,
+ modestBranding: this.options.modestBranding,
+ nocookie: this.options.nocookie,
+ origin: this.options.origin,
+ playlist: this.options.playlist,
+ progressBarColor: this.options.progressBarColor,
+ startAt: HTMLAttributes.start || 0,
+ });
+
+ HTMLAttributes.src = embedUrl;
+
+ return [
+ "div",
+ {
+ "data-youtube-video": "",
+ class: cn("youtube aspect-w-16 aspect-h-9 w-full"),
+ },
+ [
+ "iframe",
+ mergeAttributes(
+ this.options.HTMLAttributes,
+ {
+ width: this.options.width,
+ height: this.options.height,
+ allowfullscreen: this.options.allowFullscreen,
+ autoplay: this.options.autoplay,
+ ccLanguage: this.options.ccLanguage,
+ ccLoadPolicy: this.options.ccLoadPolicy,
+ disableKBcontrols: this.options.disableKBcontrols,
+ enableIFrameApi: this.options.enableIFrameApi,
+ endTime: this.options.endTime,
+ interfaceLanguage: this.options.interfaceLanguage,
+ ivLoadPolicy: this.options.ivLoadPolicy,
+ loop: this.options.loop,
+ modestBranding: this.options.modestBranding,
+ origin: this.options.origin,
+ playlist: this.options.playlist,
+ progressBarColor: this.options.progressBarColor,
+ },
+ HTMLAttributes
+ ),
+ ],
+ ];
+ },
+});
diff --git a/src/components/editor/generative/ai-completion-command.tsx b/src/components/editor/generative/ai-completion-command.tsx
new file mode 100644
index 0000000..be9d80a
--- /dev/null
+++ b/src/components/editor/generative/ai-completion-command.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { useEditor } from "novel";
+import { Check, TextQuote, TrashIcon } from "lucide-react";
+import {
+ CommandGroup,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+
+const AICompletionCommands = ({
+ completion,
+ onDiscard,
+}: {
+ completion: string;
+ onDiscard: () => void;
+}) => {
+ const { editor } = useEditor();
+ return (
+
+
+ {
+ const selection = editor!.view.state.selection;
+
+ editor!
+ .chain()
+ .focus()
+ .insertContentAt(
+ {
+ from: selection.from,
+ to: selection.to,
+ },
+ completion
+ )
+ .run();
+ }}
+ >
+
+ Replace selection
+
+ {
+ const selection = editor!.view.state.selection;
+ editor!
+ .chain()
+ .focus()
+ .insertContentAt(selection.to + 1, completion)
+ .run();
+ }}
+ >
+
+ Insert below
+
+
+
+
+
+
+
+ Discard
+
+
+
+ );
+};
+
+export default AICompletionCommands;
diff --git a/src/components/editor/generative/ai-selector-commands.tsx b/src/components/editor/generative/ai-selector-commands.tsx
new file mode 100644
index 0000000..c22d1db
--- /dev/null
+++ b/src/components/editor/generative/ai-selector-commands.tsx
@@ -0,0 +1,104 @@
+import {
+ CommandGroup,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command";
+import {
+ ArrowDownWideNarrow,
+ CheckCheck,
+ RefreshCcwDot,
+ StepForward,
+ WrapText,
+} from "lucide-react";
+import { useEditor } from "novel";
+import { getPrevText } from "novel/utils";
+
+const options = [
+ {
+ value: "improve",
+ label: "Improve writing",
+ icon: RefreshCcwDot,
+ },
+
+ {
+ value: "fix",
+ label: "Fix grammar",
+ icon: CheckCheck,
+ },
+ {
+ value: "shorter",
+ label: "Make shorter",
+ icon: ArrowDownWideNarrow,
+ },
+ {
+ value: "longer",
+ label: "Make longer",
+ icon: WrapText,
+ },
+];
+
+interface AISelectorCommandsProps {
+ onSelect: (value: string, option: string) => void;
+}
+
+const AISelectorCommands = ({ onSelect }: AISelectorCommandsProps) => {
+ const { editor } = useEditor();
+
+ return (
+
+
+ {options.map((option) => (
+ {
+ if (!editor) return;
+ const slice = editor.state.selection.content();
+
+ let text = "";
+
+ if (editor?.isActive("math")) {
+ text = editor.getAttributes("math").latex;
+ } else {
+ text = editor!.storage.markdown.serializer.serialize(
+ slice.content
+ );
+ }
+ onSelect(text, value);
+ }}
+ className="flex gap-2 px-4"
+ key={option.value}
+ value={option.value}
+ >
+
+ {option.label}
+
+ ))}
+
+
+
+ {
+ if (!editor) return;
+ const pos = editor.state.selection.from;
+
+ let text = "";
+
+ if (editor?.isActive("math")) {
+ text = editor.getAttributes("math").latex;
+ } else {
+ text = getPrevText(editor, pos);
+ }
+ onSelect(text, "continue");
+ }}
+ value="continue"
+ className="gap-2 px-4"
+ >
+
+ Continue writing
+
+
+
+ );
+};
+
+export default AISelectorCommands;
diff --git a/src/components/editor/generative/ai-selector.tsx b/src/components/editor/generative/ai-selector.tsx
new file mode 100644
index 0000000..b3ead4a
--- /dev/null
+++ b/src/components/editor/generative/ai-selector.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { Command, CommandInput } from "@/components/ui/command";
+
+import { Button } from "@/components/ui/button";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { TypingSpinner } from "@/components/ui/typing-spinner";
+import { useToast } from "@/components/ui/use-toast";
+import { generateAICompletion } from "@/lib/actions";
+import { AiPromptRequest } from "@/lib/models";
+import { parseErrorResponse } from "@/lib/parse-error-response";
+import { readStreamableValue } from "ai/rsc";
+import { ArrowUp, Sparkles } from "lucide-react";
+import { useEditor } from "novel";
+import { useState } from "react";
+import Markdown from "react-markdown";
+import AICompletionCommands from "./ai-completion-command";
+import AISelectorCommands from "./ai-selector-commands";
+
+interface AISelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function AISelector({ open, onOpenChange }: AISelectorProps) {
+ const { editor } = useEditor();
+ const [inputValue, setInputValue] = useState("");
+ const [completion, setCompletion] = useState("");
+ const [isLoading, setLoading] = useState(false);
+ const { toast } = useToast();
+
+ const handleCompletion = async (values: AiPromptRequest) => {
+ try {
+ setLoading(true);
+ const result = await generateAICompletion(values);
+ let textContent = "";
+
+ for await (const delta of readStreamableValue(result)) {
+ textContent = `${textContent}${delta}`;
+
+ setCompletion(textContent);
+ }
+ setInputValue("");
+ } catch (error) {
+ toast({
+ title: "Error",
+ description: parseErrorResponse(error),
+ variant: "destructive",
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const hasCompletion = completion.length > 0;
+
+ return (
+
+ {hasCompletion && (
+
+ )}
+
+ {isLoading && (
+
+
+ AI is thinking
+
+
+
+
+ )}
+ {!isLoading && (
+ <>
+
+
{
+ // addAIHighlight(editor!)
+ }}
+ />
+ {
+ if (completion) {
+ handleCompletion({
+ prompt: completion,
+ option: "zap",
+ command: inputValue,
+ });
+
+ return;
+ }
+
+ const slice = editor!.state.selection.content();
+
+ let text = "";
+
+ if (editor?.isActive("math")) {
+ text = editor.getAttributes("math").latex;
+ } else {
+ text = editor!.storage.markdown.serializer.serialize(
+ slice.content
+ );
+ }
+
+ handleCompletion({
+ prompt: text,
+ option: "zap",
+ command: inputValue,
+ });
+ }}
+ >
+
+
+
+ {hasCompletion ? (
+ {
+ editor?.chain().unsetHighlight().focus().run();
+ onOpenChange(false);
+ }}
+ completion={completion}
+ />
+ ) : (
+
+ handleCompletion({
+ prompt: value,
+ option: option,
+ command: "",
+ })
+ }
+ />
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/editor/generative/generative-menu-switch.tsx b/src/components/editor/generative/generative-menu-switch.tsx
new file mode 100644
index 0000000..1d28908
--- /dev/null
+++ b/src/components/editor/generative/generative-menu-switch.tsx
@@ -0,0 +1,84 @@
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { isTextSelection } from "@tiptap/core";
+import { SmilePlus } from "lucide-react";
+import { EditorBubble, useEditor } from "novel";
+import { useEffect, type ReactNode } from "react";
+import { AISelector } from "./ai-selector";
+
+interface GenerativeMenuSwitchProps {
+ children: ReactNode;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+const GenerativeMenuSwitch = ({
+ children,
+ open,
+ onOpenChange,
+}: GenerativeMenuSwitchProps) => {
+ const { editor } = useEditor();
+
+ useEffect(() => {
+ if (!open && editor) {
+ // removeAIHighlight(editor);
+ }
+ }, [open, editor]);
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+ {
+ onOpenChange(false);
+ editor.chain().unsetHighlight().run();
+ },
+ appendTo: "parent",
+ }}
+ className={cn(
+ "flex w-fit max-w-[90vw] overflow-x-auto scrollbar-hide rounded-md border bg-background shadow-xl",
+ open ? "mb-10" : undefined
+ )}
+ shouldShow={({ editor, view, state, from, to }) => {
+ const { selection } = state;
+ const { empty } = selection;
+
+ // don't show bubble menu if:
+ // - the editor is not editable
+ // - the selected node is an image
+ // - the selection is empty
+ // - the selection is a node selection (for drag handles)
+ if (
+ !editor.isEditable ||
+ editor.isActive("image") ||
+ empty ||
+ !isTextSelection(selection)
+ ) {
+ return false;
+ }
+ return true;
+ }}
+ >
+ {open && }
+ {!open && (
+
+ onOpenChange(true)}
+ size="sm"
+ >
+
+ Ask AI
+
+ {children}
+
+ )}
+
+ );
+};
+
+export default GenerativeMenuSwitch;
diff --git a/src/components/editor/index.ts b/src/components/editor/index.ts
new file mode 100644
index 0000000..96414aa
--- /dev/null
+++ b/src/components/editor/index.ts
@@ -0,0 +1,2 @@
+export { default as NovelEditor } from "./novel-editor";
+export * from "./content-renderer";
diff --git a/src/components/editor/novel-editor.tsx b/src/components/editor/novel-editor.tsx
new file mode 100644
index 0000000..5866734
--- /dev/null
+++ b/src/components/editor/novel-editor.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import { uploadImage } from "@/lib/actions";
+import { cn, debounce } from "@/lib/utils";
+import {
+ EditorCommand,
+ EditorCommandEmpty,
+ EditorCommandItem,
+ EditorCommandList,
+ EditorContent,
+ EditorInstance,
+ EditorRoot,
+ JSONContent,
+} from "novel";
+import { handleCommandNavigation } from "novel/extensions";
+import {
+ createImageUpload,
+ handleImageDrop,
+ handleImagePaste,
+} from "novel/plugins";
+import { useState } from "react";
+import { Separator } from "../ui/separator";
+import { useToast } from "../ui/use-toast";
+import { defaultExtensions } from "./extensions";
+import GenerativeMenuSwitch from "./generative/generative-menu-switch";
+import { ColorSelector } from "./selectors/color-selector";
+import { LinkSelector } from "./selectors/link-selector";
+import { NodeSelector } from "./selectors/node-selector";
+import { TextButtons } from "./selectors/text-buttons";
+import { slashCommand, suggestionItems } from "./slash-command";
+import { MathSelector } from "./selectors/math-selector";
+import { TextAlignSelector } from "./selectors/text-align-selector";
+import TableFloatingMenu from "./table-floating-menu";
+
+const extensions = [...defaultExtensions, slashCommand];
+
+interface NovelEditorProps {
+ content?: JSONContent;
+ onCreate?: (editor: EditorInstance) => void;
+ onChange?: (editor: EditorInstance) => void;
+ onDebouncedChange?: (content: JSONContent, wordCount: number) => void;
+}
+
+function NovelEditor({
+ content,
+ onCreate,
+ onChange,
+ onDebouncedChange,
+}: NovelEditorProps) {
+ const [openNode, setOpenNode] = useState(false);
+ const [openColor, setOpenColor] = useState(false);
+ const [openLink, setOpenLink] = useState(false);
+ const [openTextAlign, setOpenTextAlign] = useState(false);
+ const [openAI, setOpenAI] = useState(false);
+
+ const { toast } = useToast();
+
+ const debouncedUpdate = debounce((editor: EditorInstance) => {
+ const json = editor.getJSON();
+ const wordCount = editor.storage.characterCount.words();
+ onDebouncedChange?.(json, wordCount ?? 0);
+ }, 2000);
+
+ const onUpload = async (file: File) => {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ //This should return a src of the uploaded image
+ return await uploadImage(formData);
+ };
+
+ const uploadFn = createImageUpload({
+ onUpload,
+ validateFn: (file) => {
+ if (!file.type.includes("image/")) {
+ toast({
+ title: "Error",
+ description: "File type not supported.",
+ variant: "destructive",
+ });
+ return false;
+ } else if (file.size / 1024 / 1024 > 1) {
+ toast({
+ title: "Error",
+ description: "File size too big (max 1MB).",
+ variant: "destructive",
+ });
+ return false;
+ }
+ return true;
+ },
+ });
+
+ return (
+
+ {
+ onChange?.(editor);
+ // debouncedUpdate(editor);
+ }}
+ onCreate={({ editor }) => onCreate?.(editor)}
+ // slotAfter={ }
+ editorProps={{
+ handleDOMEvents: {
+ keydown: (_view, event) => handleCommandNavigation(event),
+ },
+ attributes: {
+ class: cn(
+ `prose dark:prose-invert prose-headings:font-title font-default focus:outline-none max-w-full`
+ ),
+ },
+ handlePaste: (view, event) => handleImagePaste(view, event, uploadFn),
+ handleDrop: (view, event, _slice, moved) =>
+ handleImageDrop(view, event, moved, uploadFn),
+ }}
+ >
+
+
+ No results
+
+
+ {suggestionItems.map((item) => (
+ item.command?.(val)}
+ className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-accent aria-selected:bg-accent cursor-pointer`}
+ key={item.title}
+ >
+
+ {item.icon}
+
+
+
{item.title}
+
+ {item.description}
+
+
+
+ ))}
+
+
+ {/*
+
+
+
+
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default NovelEditor;
diff --git a/src/components/editor/selectors/color-selector.tsx b/src/components/editor/selectors/color-selector.tsx
new file mode 100644
index 0000000..47c5e24
--- /dev/null
+++ b/src/components/editor/selectors/color-selector.tsx
@@ -0,0 +1,179 @@
+import { Check, ChevronDown } from "lucide-react";
+import { EditorBubbleItem, useEditor } from "novel";
+
+import { PopoverTrigger, Popover, PopoverContent } from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+
+export interface BubbleColorMenuItem {
+ name: string;
+ color: string;
+}
+
+interface ColorSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+// interface ColorSelectorProps {
+// isOpen: boolean;
+// setIsOpen: Dispatch>;
+// }
+
+const TEXT_COLORS: BubbleColorMenuItem[] = [
+ {
+ name: "Default",
+ color: "var(--novel-black)",
+ },
+ {
+ name: "Purple",
+ color: "#9333EA",
+ },
+ {
+ name: "Red",
+ color: "#E00000",
+ },
+ {
+ name: "Yellow",
+ color: "#EAB308",
+ },
+ {
+ name: "Blue",
+ color: "#2563EB",
+ },
+ {
+ name: "Green",
+ color: "#008A00",
+ },
+ {
+ name: "Orange",
+ color: "#FFA500",
+ },
+ {
+ name: "Pink",
+ color: "#BA4081",
+ },
+ {
+ name: "Gray",
+ color: "#A8A29E",
+ },
+];
+
+const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
+ {
+ name: "Default",
+ color: "var(--novel-highlight-default)",
+ },
+ {
+ name: "Purple",
+ color: "var(--novel-highlight-purple)",
+ },
+ {
+ name: "Red",
+ color: "var(--novel-highlight-red)",
+ },
+ {
+ name: "Yellow",
+ color: "var(--novel-highlight-yellow)",
+ },
+ {
+ name: "Blue",
+ color: "var(--novel-highlight-blue)",
+ },
+ {
+ name: "Green",
+ color: "var(--novel-highlight-green)",
+ },
+ {
+ name: "Orange",
+ color: "var(--novel-highlight-orange)",
+ },
+ {
+ name: "Pink",
+ color: "var(--novel-highlight-pink)",
+ },
+ {
+ name: "Gray",
+ color: "var(--novel-highlight-gray)",
+ },
+];
+
+export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
+ const { editor } = useEditor();
+
+ if (!editor) return null;
+ const activeColorItem = TEXT_COLORS.find(({ color }) => editor.isActive("textStyle", { color }));
+
+ const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
+ editor.isActive("highlight", { color })
+ );
+
+ return (
+
+
+
+
+ A
+
+
+
+
+
+
+
+
Color
+ {TEXT_COLORS.map(({ name, color }, index) => (
+
{
+ editor.commands.unsetColor();
+ name !== "Default" &&
+ editor
+ .chain()
+ .focus()
+ .setColor(color || "")
+ .run();
+ }}
+ className='flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent'>
+
+
+ ))}
+
+
+
Background
+ {HIGHLIGHT_COLORS.map(({ name, color }, index) => (
+
{
+ editor.commands.unsetHighlight();
+ name !== "Default" && editor.commands.setHighlight({ color });
+ }}
+ className='flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent'>
+
+ {editor.isActive("highlight", { color }) && }
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/components/editor/selectors/link-selector.tsx b/src/components/editor/selectors/link-selector.tsx
new file mode 100644
index 0000000..afc947d
--- /dev/null
+++ b/src/components/editor/selectors/link-selector.tsx
@@ -0,0 +1,95 @@
+import { Button } from "@/components/ui/button";
+import { PopoverContent } from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import { Popover, PopoverTrigger } from "@radix-ui/react-popover";
+import { Check, Trash } from "lucide-react";
+import { useEditor } from "novel";
+import { useEffect, useRef } from "react";
+
+export function isValidUrl(url: string) {
+ try {
+ new URL(url);
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+export function getUrlFromString(str: string) {
+ if (isValidUrl(str)) return str;
+ try {
+ if (str.includes(".") && !str.includes(" ")) {
+ return new URL(`https://${str}`).toString();
+ }
+ } catch (e) {
+ return null;
+ }
+}
+interface LinkSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {
+ const inputRef = useRef(null);
+ const { editor } = useEditor();
+
+ // Autofocus on input by default
+ useEffect(() => {
+ inputRef.current && inputRef.current?.focus();
+ });
+ if (!editor) return null;
+
+ return (
+
+
+
+ ↗
+
+ Link
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/editor/selectors/math-selector.tsx b/src/components/editor/selectors/math-selector.tsx
new file mode 100644
index 0000000..563b634
--- /dev/null
+++ b/src/components/editor/selectors/math-selector.tsx
@@ -0,0 +1,57 @@
+import { FunctionIcon } from "@/components/icons";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipArrow,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "novel";
+
+export const MathSelector = () => {
+ const { editor } = useEditor();
+
+ if (!editor) return null;
+
+ return (
+
+
+
+ {editor.isActive("math") ? (
+ {
+ editor.chain().focus().unsetLatex().run();
+ }}
+ >
+
+
+ ) : (
+ {
+ const { from, to } = editor.state.selection;
+ const latex = editor.state.doc.textBetween(from, to);
+
+ if (!latex) return;
+
+ editor.chain().focus().setLatex({ latex }).run();
+ }}
+ >
+
+
+ )}
+
+
+ LaTex
+
+
+
+
+ );
+};
diff --git a/src/components/editor/selectors/node-selector.tsx b/src/components/editor/selectors/node-selector.tsx
new file mode 100644
index 0000000..e0fce96
--- /dev/null
+++ b/src/components/editor/selectors/node-selector.tsx
@@ -0,0 +1,139 @@
+import {
+ Check,
+ ChevronDown,
+ Heading1,
+ Heading2,
+ Heading3,
+ TextQuote,
+ ListOrdered,
+ TextIcon,
+ Code,
+ CheckSquare,
+ type LucideIcon,
+ Divide,
+ } from "lucide-react";
+ import { EditorBubbleItem, useEditor } from "novel";
+
+ import { Popover } from "@radix-ui/react-popover";
+ import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+ import { Button } from "@/components/ui/button";
+
+ export type SelectorItem = {
+ name: string;
+ icon: LucideIcon;
+ command: (editor: ReturnType["editor"]) => void;
+ isActive: (editor: ReturnType["editor"]) => boolean;
+ };
+
+ const items: SelectorItem[] = [
+ {
+ name: "Text",
+ icon: TextIcon,
+ command: (editor) => editor!.chain().focus().toggleNode("paragraph", "paragraph").run(),
+ // I feel like there has to be a more efficient way to do this – feel free to PR if you know how!
+ isActive: (editor) =>
+ editor!.isActive("paragraph") &&
+ !editor!.isActive("bulletList") &&
+ !editor!.isActive("orderedList"),
+ },
+ {
+ name: "Heading 1",
+ icon: Heading1,
+ command: (editor) => editor!.chain().focus().toggleHeading({ level: 1 }).run(),
+ isActive: (editor) => editor!.isActive("heading", { level: 1 }),
+ },
+ {
+ name: "Heading 2",
+ icon: Heading2,
+ command: (editor) => editor!.chain().focus().toggleHeading({ level: 2 }).run(),
+ isActive: (editor) => editor!.isActive("heading", { level: 2 }),
+ },
+ {
+ name: "Heading 3",
+ icon: Heading3,
+ command: (editor) => editor!.chain().focus().toggleHeading({ level: 3 }).run(),
+ isActive: (editor) => editor!.isActive("heading", { level: 3 }),
+ },
+ {
+ name: "To-do List",
+ icon: CheckSquare,
+ command: (editor) => editor!.chain().focus().toggleTaskList().run(),
+ isActive: (editor) => editor!.isActive("taskItem"),
+ },
+ {
+ name: "Bullet List",
+ icon: ListOrdered,
+ command: (editor) => editor!.chain().focus().toggleBulletList().run(),
+ isActive: (editor) => editor!.isActive("bulletList"),
+ },
+ {
+ name: "Numbered List",
+ icon: ListOrdered,
+ command: (editor) => editor!.chain().focus().toggleOrderedList().run(),
+ isActive: (editor) => editor!.isActive("orderedList"),
+ },
+ {
+ name: "Quote",
+ icon: TextQuote,
+ command: (editor) =>
+ editor!.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
+ isActive: (editor) => editor!.isActive("blockquote"),
+ },
+ {
+ name: "Code",
+ icon: Code,
+ command: (editor) => editor!.chain().focus().toggleCodeBlock().run(),
+ isActive: (editor) => editor!.isActive("codeBlock"),
+ },
+ // {
+ // name: "Divider",
+ // icon: Divide,
+ // command: (editor) => editor!.chain().focus().setHorizontalRule().run(),
+ // isActive: (editor) => editor!.isActive("horizontalRule"),
+ // },
+ ];
+ interface NodeSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ }
+
+ export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {
+ const { editor } = useEditor();
+ if (!editor) return null;
+ const activeItem = items.filter((item) => item.isActive(editor)).pop() ?? {
+ name: "Multiple",
+ };
+
+ return (
+
+
+
+ {activeItem.name}
+
+
+
+
+ {items.map((item, index) => (
+ {
+ item.command(editor);
+ onOpenChange(false);
+ }}
+ className='flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent'>
+
+ {activeItem.name === item.name && }
+
+ ))}
+
+
+ );
+ };
+
\ No newline at end of file
diff --git a/src/components/editor/selectors/text-align-selector.tsx b/src/components/editor/selectors/text-align-selector.tsx
new file mode 100644
index 0000000..405a1dd
--- /dev/null
+++ b/src/components/editor/selectors/text-align-selector.tsx
@@ -0,0 +1,76 @@
+import { AlignCenter, AlignLeft, AlignRight, ChevronDown } from "lucide-react";
+import { EditorBubbleItem, useEditor } from "novel";
+
+import { Button } from "@/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+interface TextAlignSelectorProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export const TextAlignSelector = ({
+ open,
+ onOpenChange,
+}: TextAlignSelectorProps) => {
+ const { editor } = useEditor();
+
+ if (!editor) return null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
{
+ editor.chain().focus().setTextAlign("left").run();
+ }}
+ className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
+ >
+
+
+
{
+ editor.chain().focus().setTextAlign("center").run();
+ }}
+ className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
+ >
+
+
+
{
+ editor.chain().focus().setTextAlign("right").run();
+ }}
+ className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
+ >
+
+
+
+
+
+ );
+};
diff --git a/src/components/editor/selectors/text-buttons.tsx b/src/components/editor/selectors/text-buttons.tsx
new file mode 100644
index 0000000..48e4fb4
--- /dev/null
+++ b/src/components/editor/selectors/text-buttons.tsx
@@ -0,0 +1,71 @@
+import { cn } from "@/lib/utils";
+import { EditorBubbleItem, useEditor } from "novel";
+import {
+ BoldIcon,
+ ItalicIcon,
+ UnderlineIcon,
+ StrikethroughIcon,
+ CodeIcon,
+} from "lucide-react";
+import type { SelectorItem } from "./node-selector";
+import { Button } from "@/components/ui/button";
+
+export const TextButtons = () => {
+ const { editor } = useEditor();
+ if (!editor) return null;
+ const items: SelectorItem[] = [
+ {
+ name: "bold",
+ isActive: (editor) => editor!.isActive("bold"),
+ command: (editor) => editor!.chain().focus().toggleBold().run(),
+ icon: BoldIcon,
+ },
+ {
+ name: "italic",
+ isActive: (editor) => editor!.isActive("italic"),
+ command: (editor) => editor!.chain().focus().toggleItalic().run(),
+ icon: ItalicIcon,
+ },
+ {
+ name: "underline",
+ isActive: (editor) => editor!.isActive("underline"),
+ command: (editor) => editor!.chain().focus().toggleUnderline().run(),
+ icon: UnderlineIcon,
+ },
+ {
+ name: "strike",
+ isActive: (editor) => editor!.isActive("strike"),
+ command: (editor) => editor!.chain().focus().toggleStrike().run(),
+ icon: StrikethroughIcon,
+ },
+ {
+ name: "code",
+ isActive: (editor) => editor!.isActive("code"),
+ command: (editor) => editor!.chain().focus().toggleCode().run(),
+ icon: CodeIcon,
+ },
+ ];
+ return (
+
+ {items.map((item, index) => (
+ {
+ item.command(editor);
+ }}
+ >
+
+
+
+
+ ))}
+
+ );
+};
diff --git a/src/components/editor/slash-command.tsx b/src/components/editor/slash-command.tsx
new file mode 100644
index 0000000..8bdcf46
--- /dev/null
+++ b/src/components/editor/slash-command.tsx
@@ -0,0 +1,204 @@
+import {
+ CheckSquare,
+ Code,
+ Divide,
+ Heading1,
+ Heading2,
+ Heading3,
+ List,
+ ListOrdered,
+ Table,
+ Text,
+ TextQuote,
+} from "lucide-react";
+import { createSuggestionItems } from "novel/extensions";
+// import { startImageUpload } from "novel/plugins";
+import { Command, renderItems } from "novel/extensions";
+import { YoutubeIcon } from "../icons";
+
+const iconSize = 18;
+
+export const suggestionItems = createSuggestionItems([
+ {
+ title: "Text",
+ description: "Just start typing with plain text.",
+ searchTerms: ["p", "paragraph"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .run();
+ },
+ },
+ {
+ title: "To-do List",
+ description: "Track tasks with a to-do list.",
+ searchTerms: ["todo", "task", "list", "check", "checkbox"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleTaskList().run();
+ },
+ },
+ {
+ title: "Heading 1",
+ description: "Big section heading.",
+ searchTerms: ["title", "big", "large"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 1 })
+ .run();
+ },
+ },
+ {
+ title: "Heading 2",
+ description: "Medium section heading.",
+ searchTerms: ["subtitle", "medium"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 2 })
+ .run();
+ },
+ },
+ {
+ title: "Heading 3",
+ description: "Small section heading.",
+ searchTerms: ["subtitle", "small"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setNode("heading", { level: 3 })
+ .run();
+ },
+ },
+ {
+ title: "Bullet List",
+ description: "Create a simple bullet list.",
+ searchTerms: ["unordered", "point"],
+ icon:
,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleBulletList().run();
+ },
+ },
+ {
+ title: "Numbered List",
+ description: "Create a list with numbering.",
+ searchTerms: ["ordered"],
+ icon: ,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).toggleOrderedList().run();
+ },
+ },
+ {
+ title: "Quote",
+ description: "Capture a quote.",
+ searchTerms: ["blockquote"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleNode("paragraph", "paragraph")
+ .toggleBlockquote()
+ .run(),
+ },
+ {
+ title: "Code",
+ description: "Capture a code snippet.",
+ searchTerms: ["codeblock"],
+ icon:
,
+ command: ({ editor, range }) =>
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .toggleCodeBlock({ language: "plaintext" })
+ .run(),
+ },
+ {
+ title: "Table",
+ description: "Capture a table.",
+ searchTerms: ["table"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor.chain().focus().deleteRange(range).insertTable().run(),
+ },
+ // {
+ // title: "Image",
+ // description: "Upload an image from your computer.",
+ // searchTerms: ["photo", "picture", "media"],
+ // icon: ,
+ // command: ({ editor, range }) => {
+ // editor.chain().focus().deleteRange(range).run();
+ // // upload image
+ // const input = document.createElement("input");
+ // input.type = "file";
+ // input.accept = "image/*";
+ // input.onchange = async () => {
+ // if (input.files?.length) {
+ // const file = input.files[0];
+ // const pos = editor.view.state.selection.from;
+ // uploadFn(file, editor.view, pos);
+ // }
+ // };
+ // input.click();
+ // },
+ // },
+ {
+ title: "Youtube",
+ description: "Embed a Youtube video.",
+ searchTerms: ["video", "youtube", "embed"],
+ icon: ,
+ command: ({ editor, range }) => {
+ const videoLink = prompt("Please enter Youtube Video Link");
+ //From https://regexr.com/3dj5t
+ const ytregex = new RegExp(
+ /^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/
+ );
+
+ if (videoLink && ytregex.test(videoLink)) {
+ editor
+ .chain()
+ .focus()
+ .deleteRange(range)
+ .setYoutubeVideo({
+ src: videoLink,
+ })
+ .run();
+ } else {
+ if (videoLink !== null) {
+ alert("Please enter a correct Youtube Video Link");
+ }
+ }
+ },
+ },
+ {
+ title: "Divider",
+ description: "Create a horizontal divider.",
+ searchTerms: ["divider"],
+ icon: ,
+ command: ({ editor, range }) =>
+ editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
+ },
+]);
+
+export const slashCommand = Command.configure({
+ suggestion: {
+ items: () => suggestionItems,
+ render: renderItems,
+ },
+});
diff --git a/src/components/editor/table-floating-menu.tsx b/src/components/editor/table-floating-menu.tsx
new file mode 100644
index 0000000..147c886
--- /dev/null
+++ b/src/components/editor/table-floating-menu.tsx
@@ -0,0 +1,283 @@
+import { cn } from "@/lib/utils";
+import { FloatingMenu } from "@tiptap/react";
+import {
+ Columns,
+ MoreHorizontal,
+ RectangleHorizontal,
+ Rows,
+} from "lucide-react";
+import { EditorBubbleItem, useEditor } from "novel";
+import { Button } from "../ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
+import {
+ Tooltip,
+ TooltipArrow,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../ui/tooltip";
+
+export default function TableFloatingMenu() {
+ const { editor } = useEditor();
+
+ if (!editor) {
+ return null;
+ }
+
+ return (
+ {
+ const { ranges } = editor.state.selection;
+ const from = Math.min(...ranges.map((range) => range.$from.pos));
+ const to = Math.max(...ranges.map((range) => range.$to.pos));
+
+ let nodePos: number | undefined = undefined;
+
+ editor.state.doc.nodesBetween(from, to, (node, p) => {
+ nodePos = p;
+ return false;
+ });
+
+ if (nodePos) {
+ const node = editor.view.nodeDOM(nodePos) as HTMLElement;
+
+ if (node) {
+ return node.getBoundingClientRect();
+ }
+ }
+
+ return editor.view.dom.getBoundingClientRect();
+ },
+ }}
+ className={cn("flex w-fit max-w-[90vw] space-x-0.5")}
+ shouldShow={({ editor }) => {
+ return editor.isActive("table");
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ Column
+
+
+
+
+
+ {
+ editor.chain().focus().addColumnBefore().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Add column before
+
+ {
+ editor.chain().focus().addColumnAfter().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Add column after
+
+ {
+ editor.chain().focus().deleteColumn().run();
+ }}
+ className="px-2 py-1.5 text-sm text-destructive hover:bg-accent"
+ role="button"
+ >
+ Delete column
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Row
+
+
+
+
+
+ {
+ editor.chain().focus().addRowBefore().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Add row before
+
+ {
+ editor.chain().focus().addRowAfter().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Add row after
+
+ {
+ editor.chain().focus().deleteRow().run();
+ }}
+ className="px-2 py-1.5 text-sm text-destructive hover:bg-accent"
+ role="button"
+ >
+ Delete row
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cell
+
+
+
+
+
+ {
+ editor.chain().focus().mergeCells().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Merge cells
+
+ {
+ editor.chain().focus().splitCell().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Split cell
+
+ {
+ editor.chain().focus().toggleHeaderCell().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Toggle header cell
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Options
+
+
+
+
+
+ {
+ editor.chain().focus().toggleHeaderRow().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Toggle header row
+
+ {
+ editor.chain().focus().toggleHeaderColumn().run();
+ }}
+ className="px-2 py-1.5 text-sm hover:bg-accent"
+ role="button"
+ >
+ Toggle header col
+
+ {
+ editor.chain().focus().deleteTable().run();
+ }}
+ className="px-2 py-1.5 text-sm text-destructive hover:bg-accent"
+ role="button"
+ >
+ Delete table
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/editor/utilities/table-view.ts b/src/components/editor/utilities/table-view.ts
new file mode 100644
index 0000000..d0f4d2d
--- /dev/null
+++ b/src/components/editor/utilities/table-view.ts
@@ -0,0 +1,106 @@
+// @ts-nocheck
+import { cn } from "@/lib/utils";
+import { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { NodeView } from "@tiptap/pm/view";
+
+export function updateColumns(
+ node: ProseMirrorNode,
+ colgroup: Element,
+ table: Element,
+ cellMinWidth: number,
+ overrideCol?: number,
+ overrideValue?: any
+) {
+ let totalWidth = 0;
+ let fixedWidth = true;
+ let nextDOM = colgroup.firstChild;
+ const row = node.firstChild;
+
+ for (let i = 0, col = 0; i < row.childCount; i += 1) {
+ const { colspan, colwidth } = row.child(i).attrs;
+
+ for (let j = 0; j < colspan; j += 1, col += 1) {
+ const hasWidth =
+ overrideCol === col ? overrideValue : colwidth && colwidth[j];
+ const cssWidth = hasWidth ? `${hasWidth}px` : "";
+
+ totalWidth += hasWidth || cellMinWidth;
+
+ if (!hasWidth) {
+ fixedWidth = false;
+ }
+
+ if (!nextDOM) {
+ colgroup.appendChild(document.createElement("col")).style.width =
+ cssWidth;
+ } else {
+ if (nextDOM.style.width !== cssWidth) {
+ nextDOM.style.width = cssWidth;
+ }
+
+ nextDOM = nextDOM.nextSibling;
+ }
+ }
+ }
+
+ while (nextDOM) {
+ const after = nextDOM.nextSibling;
+
+ nextDOM.parentNode.removeChild(nextDOM);
+ nextDOM = after;
+ }
+
+ if (fixedWidth) {
+ table.style.width = `${totalWidth}px`;
+ table.style.minWidth = "";
+ } else {
+ table.style.width = "";
+ table.style.minWidth = `${totalWidth}px`;
+ }
+}
+
+export class TableView implements NodeView {
+ node: ProseMirrorNode;
+
+ cellMinWidth: number;
+
+ dom: Element;
+
+ table: Element;
+
+ colgroup: Element;
+
+ contentDOM: Element;
+
+ constructor(node: ProseMirrorNode, cellMinWidth: number) {
+ this.node = node;
+ this.cellMinWidth = cellMinWidth;
+ this.dom = document.createElement("div");
+ this.dom.className = cn("table-wrapper overflow-x-auto");
+ this.table = this.dom.appendChild(document.createElement("table"));
+ this.colgroup = this.table.appendChild(document.createElement("colgroup"));
+ updateColumns(node, this.colgroup, this.table, cellMinWidth);
+ this.contentDOM = this.table.appendChild(document.createElement("tbody"));
+ }
+
+ update(node: ProseMirrorNode) {
+ if (node.type !== this.node.type) {
+ return false;
+ }
+
+ this.node = node;
+ updateColumns(node, this.colgroup, this.table, this.cellMinWidth);
+
+ return true;
+ }
+
+ ignoreMutation(
+ mutation: MutationRecord | { type: "selection"; target: Element }
+ ) {
+ return (
+ mutation.type === "attributes" &&
+ (mutation.target === this.table ||
+ this.colgroup.contains(mutation.target))
+ );
+ }
+}
diff --git a/src/components/forms/Input.tsx b/src/components/forms/Input.tsx
new file mode 100644
index 0000000..010ab88
--- /dev/null
+++ b/src/components/forms/Input.tsx
@@ -0,0 +1,47 @@
+import { cn } from "@/lib/utils";
+import { forwardRef, ReactNode } from "react";
+
+export interface InputProps
+ extends React.InputHTMLAttributes {
+ label?: ReactNode;
+ error?: string;
+ wrapperClass?: string;
+ hideErrorMessage?: boolean;
+}
+
+const Input = forwardRef(
+ (
+ { label, className, wrapperClass, error, hideErrorMessage, ...props },
+ ref
+ ) => {
+ const isInvalid = !!error;
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+ {error && !hideErrorMessage && (
+
{error}
+ )}
+
+ );
+ }
+);
+
+Input.displayName = "Input";
+
+export default Input;
diff --git a/src/components/forms/Select.tsx b/src/components/forms/Select.tsx
new file mode 100644
index 0000000..adb6557
--- /dev/null
+++ b/src/components/forms/Select.tsx
@@ -0,0 +1,37 @@
+import { cn } from "@/lib/utils";
+import { forwardRef, ReactNode } from "react";
+
+export interface InputProps
+ extends React.SelectHTMLAttributes {
+ label?: ReactNode;
+ error?: string;
+ wrapperClass?: string;
+ children?: ReactNode;
+}
+
+const Select = forwardRef(
+ ({ label, className, wrapperClass, error, children, ...props }, ref) => {
+ const isInvalid = !!error;
+
+ return (
+
+ {label &&
{label} }
+
+ {children}
+
+ {error &&
{error}
}
+
+ );
+ }
+);
+
+Select.displayName = "Select";
+
+export default Select;
diff --git a/src/components/forms/Textarea.tsx b/src/components/forms/Textarea.tsx
new file mode 100644
index 0000000..cec1448
--- /dev/null
+++ b/src/components/forms/Textarea.tsx
@@ -0,0 +1,38 @@
+import { cn } from "@/lib/utils";
+import { ReactNode, forwardRef } from "react";
+
+export interface InputProps
+ extends React.TextareaHTMLAttributes {
+ label?: ReactNode;
+ error?: string;
+ wrapperClass?: string;
+}
+
+const Textarea = forwardRef(
+ ({ label, className, wrapperClass, error, children, ...props }, ref) => {
+ const isInvalid = !!error;
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
+ {error &&
{error}
}
+
+ );
+ }
+);
+
+Textarea.displayName = "Textarea";
+
+export default Textarea;
diff --git a/src/components/forms/index.tsx b/src/components/forms/index.tsx
new file mode 100644
index 0000000..d275fee
--- /dev/null
+++ b/src/components/forms/index.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+export { default as Input } from "./input";
+export { default as PasswordInput } from "./password-input";
+export { default as ReactSelect } from "./react-select";
+export { default as RichTextEditor } from "./rich-text-editor";
+export { default as Select } from "./select";
+export { default as Textarea } from "./textarea";
+export { default as TitleInput } from "./title-input";
+// export { default as AutocompleteSelect } from "./AutocompleteSelect";
+// export { default as DatePickerInput2 } from "./DatePickerInput2";
diff --git a/src/components/forms/password-input.tsx b/src/components/forms/password-input.tsx
new file mode 100644
index 0000000..f4d5b46
--- /dev/null
+++ b/src/components/forms/password-input.tsx
@@ -0,0 +1,50 @@
+"use client";
+import { ReactNode, forwardRef, useState } from "react";
+import { Eye, EyeOff } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface InputProps
+ extends React.InputHTMLAttributes {
+ label?: ReactNode;
+ error?: string;
+ wrapperClass?: string;
+}
+
+const PasswordInput = forwardRef(
+ ({ label, className, wrapperClass, error, ...props }, ref) => {
+ const [isPassword, setIsPassword] = useState(true);
+
+ const isInvalid = !!error;
+
+ return (
+
+ {label &&
{label} }
+
+
+
setIsPassword(!isPassword)}
+ >
+ {isPassword ? : }
+
+
+ {error &&
{error}
}
+
+ );
+ }
+);
+
+PasswordInput.displayName = "PasswordInput";
+
+export default PasswordInput;
diff --git a/src/components/forms/react-select.tsx b/src/components/forms/react-select.tsx
new file mode 100644
index 0000000..f40c6da
--- /dev/null
+++ b/src/components/forms/react-select.tsx
@@ -0,0 +1,69 @@
+import { cn } from "@/lib/utils";
+import Select, { Props } from "react-select";
+
+export default function ReactSelect<
+ Option = unknown,
+ IsMulti extends boolean = false
+>({
+ label,
+ error,
+ wrapperClass,
+ ...props
+}: Props & {
+ label?: string;
+ error?: string;
+ wrapperClass?: string;
+}) {
+ return (
+
+ {label && (
+
{label}
+ )}
+
+ styles={{
+ control: (base, state) => ({}),
+ input: (base, state) => ({
+ ...base,
+ "input:focus": {
+ boxShadow: "none",
+ },
+ }),
+ }}
+ classNames={{
+ control: ({ isFocused }) => {
+ if (error) {
+ return cn(
+ "flex py-1 rounded-md border border-destructive bg-background",
+ isFocused ? "ring-[4px] ring-danger/30" : ""
+ );
+ }
+ return cn(
+ "flex py-1 rounded-md border bg-background",
+ isFocused ? "border-primary" : "border-border"
+ );
+ },
+ multiValue: ({}) => {
+ return cn("dark:!bg-muted dark:text-muted-foreground");
+ },
+ multiValueLabel: ({}) => {
+ return cn("dark:text-muted-foreground");
+ },
+ singleValue: ({}) => {
+ return cn("!text-foreground");
+ },
+ menu: ({}) => {
+ return cn("!bg-background dark:border");
+ },
+ option: ({isSelected}) => {
+ if (isSelected) {
+ return cn("!bg-primary !text-primary-foreground");
+ }
+ return cn("!bg-background hover:!bg-muted focus:!bg-muted text-foreground");
+ },
+ }}
+ {...props}
+ />
+ {error && {error}
}
+
+ );
+}
diff --git a/src/components/forms/rich-text-editor.tsx b/src/components/forms/rich-text-editor.tsx
new file mode 100644
index 0000000..cb100c5
--- /dev/null
+++ b/src/components/forms/rich-text-editor.tsx
@@ -0,0 +1,258 @@
+"use client";
+import { Editor } from "@tinymce/tinymce-react";
+import { useTheme } from "next-themes";
+import dynamic from "next/dynamic";
+
+type OnEditorChange = (newValue: string) => void;
+
+export interface RichTextEditorInputProps {
+ id?: string;
+ value?: string;
+ placeholder?: string;
+ inline?: boolean;
+ imageUploadPath?: string;
+ onEditorChange?: OnEditorChange;
+ minHeight?: number | string;
+ noBorder?: boolean;
+ iframeEmbed?: boolean;
+}
+
+function RichTextEditor({
+ id,
+ value,
+ placeholder = "Type here...",
+ inline,
+ imageUploadPath,
+ onEditorChange,
+ minHeight = 480,
+ noBorder,
+ iframeEmbed,
+}: RichTextEditorInputProps) {
+ const { theme } = useTheme();
+
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ return (
+ {
+ onEditorChange?.(newValue);
+ }}
+ onInit={(evt, editor) => {
+ //editorRef.current = editor;
+ // editor
+ // .getContainer()
+ // .getElementsByClassName("tox-edit-area__iframe")
+ // .item(0)
+ // ?.setAttribute("style", "background-color: transparent");
+ editor.getContainer().style.borderRadius = "0.15rem 0.15rem";
+ editor.getContainer().style.border = `${
+ noBorder ? 0 : 1
+ }px solid rgba(0, 0, 0, 0.125)`;
+ }}
+ init={{
+ paste_data_images: false,
+ placeholder: placeholder,
+ height: minHeight,
+ menubar: false,
+ inline: inline ?? false,
+ skin: theme === "dark" ? "tinymce-5-dark" : "tinymce-5",
+ content_css: theme === "dark" ? "tinymce-5-dark" : "tinymce-5",
+ link_default_target: "_blank",
+ help_tabs: ["shortcuts"],
+ media_alt_source: false,
+ media_dimensions: false,
+ media_poster: false,
+ images_upload_handler: (info, progress) => {
+ return new Promise((resolve, reject) => {
+ //console.log(info.blob().size);
+
+ reject("Not implemented yet.");
+ });
+ },
+ content_style:
+ "body { font-family: Inter, Noto Sans Myanmar UI, Helvetica, Arial, sans-serif; font-size: 16px; border-radius: 0px }",
+ quickbars_insert_toolbar: false,
+ quickbars_selection_toolbar:
+ "blocks | bold italic underline strikethrough link",
+ plugins: [
+ "preview",
+ "fullscreen",
+ "wordcount",
+ "link",
+ "lists",
+ "preview",
+ "quickbars",
+ "table",
+ "code",
+ "media",
+ "autolink",
+ "help",
+ ],
+ menu: {
+ file: { title: "File", items: "preview" },
+ edit: {
+ title: "Edit",
+ items: "undo redo | cut copy paste | selectall | searchreplace",
+ },
+ view: {
+ title: "View",
+ items:
+ "code | visualaid visualchars visualblocks | spellchecker | preview fullscreen",
+ },
+ insert: {
+ title: "Insert",
+ items:
+ "link template inserttable | charmap emoticons hr | pagebreak nonbreaking anchor toc | insertdatetime",
+ },
+ format: {
+ title: "Format",
+ items:
+ "bold italic underline strikethrough superscript subscript codeformat | formats blockformats fontformats fontsizes align lineheight | removeformat",
+ },
+ tools: {
+ title: "Tools",
+ items: "spellchecker spellcheckerlanguage | code wordcount",
+ },
+ table: {
+ title: "Table",
+ items: "inserttable | cell row column | tableprops deletetable",
+ },
+ help: { title: "Help", items: "help" },
+ },
+ toolbar: [
+ // { name: "history", items: ["undo", "redo"] },
+ { name: "styles", items: ["blocks"] },
+ {
+ name: "formatting",
+ items: [
+ "bold",
+ "italic",
+ "underline",
+ "strikethrough",
+ "bullist",
+ "numlist",
+ "table",
+ ],
+ },
+ {
+ name: "alignment",
+ items: ["align"],
+ },
+ {
+ name: "indentation",
+ items: ["outdent", "indent"],
+ },
+ iframeEmbed
+ ? {
+ name: "media",
+ items: ["image", "iframe-embed-btn"],
+ }
+ : { name: "media", items: [] },
+ {
+ name: "view",
+ items: ["removeFormat", "preview", "fullscreen", "help"],
+ },
+ ],
+ setup: (editor) => {
+ editor.ui.registry.addButton("iframe-embed-btn", {
+ icon: "embed",
+ tooltip: "Embed iframe",
+ onAction: (api) => {
+ let embedCode = "";
+ let width = "";
+ let height = "";
+ let aspectRatio = "";
+ const node = editor.selection.getNode();
+ if (node.getAttribute("data-mce-object") === "iframe") {
+ embedCode = node.innerHTML;
+ const div = document.createElement("div");
+ div.innerHTML = embedCode;
+ const element = div.firstElementChild;
+ if (element instanceof HTMLIFrameElement) {
+ width = element.style.width;
+ height = element.style.height;
+ aspectRatio = element.style.aspectRatio;
+ }
+ }
+ editor.windowManager.open({
+ title: "Embed Iframe",
+ initialData: {
+ embedCode: embedCode,
+ aspectRatio: aspectRatio,
+ size: {
+ width: width,
+ height: height,
+ },
+ },
+ body: {
+ type: "panel",
+ items: [
+ {
+ type: "textarea",
+ name: "embedCode",
+ maximized: true,
+ label: "Embed code",
+ },
+ {
+ type: "sizeinput",
+ name: "size",
+ label: "Dimensions",
+ },
+ {
+ type: "input",
+ name: "aspectRatio",
+ label: "Aspect ratio",
+ },
+ ],
+ },
+ buttons: [
+ {
+ type: "cancel",
+ name: "closeButton",
+ text: "Cancel",
+ },
+ {
+ type: "submit",
+ name: "submitButton",
+ text: "Insert",
+ buttonType: "primary",
+ },
+ ],
+ onSubmit: (api) => {
+ const data = api.getData();
+ const embed = data.embedCode ?? "";
+ const div = document.createElement("div");
+ div.innerHTML = embed;
+ var iframe = div.firstElementChild;
+ if (iframe instanceof HTMLIFrameElement) {
+ iframe.style.width = data.size.width;
+ iframe.style.height = data.size.height;
+ iframe.style.aspectRatio = data.aspectRatio;
+ iframe.width = "";
+ iframe.height = "";
+ editor.execCommand(
+ "mceInsertContent",
+ false,
+ div.innerHTML
+ );
+ //editor.dispatch("onEditorChange");
+ }
+ api.close();
+ },
+ });
+ },
+ });
+ },
+ }}
+ />
+ );
+}
+
+export default dynamic(() => Promise.resolve(RichTextEditor), {
+ ssr: false,
+});
diff --git a/src/components/forms/title-input.tsx b/src/components/forms/title-input.tsx
new file mode 100644
index 0000000..7f6838e
--- /dev/null
+++ b/src/components/forms/title-input.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { useEffect, useRef } from "react";
+
+export interface InputProps
+ extends React.InputHTMLAttributes {
+ maxLength?: number;
+ wrapperClass?: string;
+}
+
+function TitleInput({
+ className,
+ wrapperClass,
+ onChange,
+ ...props
+}: InputProps) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const handleResize = () => {
+ const ta = ref.current;
+ if (ta) {
+ ta.style.height = "auto";
+ ta.style.height = `${ta.scrollHeight}px`;
+ }
+ };
+ handleResize();
+ window.addEventListener("resize", handleResize);
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ };
+ }, []);
+
+ return (
+
+
+ );
+}
+
+TitleInput.displayName = "Title Input";
+
+export default TitleInput;
diff --git a/src/components/icons/cloud-check-icon.tsx b/src/components/icons/cloud-check-icon.tsx
new file mode 100644
index 0000000..43aae25
--- /dev/null
+++ b/src/components/icons/cloud-check-icon.tsx
@@ -0,0 +1,15 @@
+export function CloudCheckIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/icons/function-icon.tsx b/src/components/icons/function-icon.tsx
new file mode 100644
index 0000000..4e92669
--- /dev/null
+++ b/src/components/icons/function-icon.tsx
@@ -0,0 +1,16 @@
+export function FunctionIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/icons/index.ts b/src/components/icons/index.ts
new file mode 100644
index 0000000..78a02fe
--- /dev/null
+++ b/src/components/icons/index.ts
@@ -0,0 +1,3 @@
+export * from "./cloud-check-icon";
+export * from "./youtube-icon";
+export * from "./function-icon";
diff --git a/src/components/icons/youtube-icon.tsx b/src/components/icons/youtube-icon.tsx
new file mode 100644
index 0000000..7cf1e84
--- /dev/null
+++ b/src/components/icons/youtube-icon.tsx
@@ -0,0 +1,16 @@
+export function YoutubeIcon({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/navigation-events.tsx b/src/components/navigation-events.tsx
new file mode 100644
index 0000000..5569c3f
--- /dev/null
+++ b/src/components/navigation-events.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { usePathname, useSearchParams } from "next/navigation";
+import NProgress from "nprogress";
+import { startTransition, useEffect } from "react";
+
+export function NavigationEvents() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ useEffect(() => {
+ startTransition(() => {
+ NProgress.done();
+ });
+ return () => {
+ NProgress.start();
+ };
+ }, [pathname, searchParams]);
+
+ return null;
+}
diff --git a/src/components/navigation-progress.tsx b/src/components/navigation-progress.tsx
new file mode 100644
index 0000000..8030bcb
--- /dev/null
+++ b/src/components/navigation-progress.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { useTheme } from "next-themes";
+import NextNProgress from "nextjs-progressbar";
+import { Suspense } from "react";
+import { NavigationEvents } from "./navigation-events";
+
+export default function NavigationProgress() {
+ const { theme } = useTheme();
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/providers.tsx b/src/components/providers.tsx
new file mode 100644
index 0000000..ead1ffc
--- /dev/null
+++ b/src/components/providers.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { NextUIProvider } from "@nextui-org/system";
+import {
+ ArcElement,
+ CategoryScale,
+ Chart,
+ DoughnutController,
+ Filler,
+ Legend,
+ LineController,
+ LineElement,
+ LinearScale,
+ PieController,
+ PointElement,
+ Tooltip,
+} from "chart.js";
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import dynamic from "next/dynamic";
+import NProgress from "nprogress";
+import { useEffect } from "react";
+import NavigationProgress from "./navigation-progress";
+import { Toaster } from "./ui/toaster";
+
+Chart.register(
+ LineController,
+ LineElement,
+ PointElement,
+ CategoryScale,
+ LinearScale,
+ PieController,
+ DoughnutController,
+ ArcElement,
+ Filler,
+ Tooltip,
+ Legend
+);
+
+type PushStateInput = [
+ data: any,
+ unused: string,
+ url?: string | URL | null | undefined
+];
+
+function Providers({ children }: { children: React.ReactNode }) {
+ useEffect(() => {
+ const handleAnchorClick = (event: MouseEvent) => {
+ const targetUrl = (event.currentTarget as HTMLAnchorElement).href;
+ const currentUrl = location.href;
+ if (targetUrl !== currentUrl && !targetUrl.includes("#")) {
+ NProgress.start();
+ }
+ };
+
+ const handleMutation: MutationCallback = () => {
+ const anchorElements = document.querySelectorAll("a");
+ anchorElements.forEach((anchor) => {
+ const isExternalLink = !anchor.href.startsWith(location.origin);
+ if (anchor.target !== "_blank" && !isExternalLink) {
+ anchor.addEventListener("click", handleAnchorClick);
+ }
+ });
+ };
+
+ const mutationObserver = new MutationObserver(handleMutation);
+ mutationObserver.observe(document, { childList: true, subtree: true });
+
+ window.history.pushState = new Proxy(window.history.pushState, {
+ apply: (target, thisArg, argArray: PushStateInput) => {
+ NProgress.done();
+ return target.apply(thisArg, argArray);
+ },
+ });
+ }, []);
+ return (
+
+
+
+
+ {children}
+
+
+ );
+}
+
+export default dynamic(() => Promise.resolve(Providers), {
+ ssr: false,
+});
diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..9a43859
--- /dev/null
+++ b/src/components/ui/accordion.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDown, Minus, Plus } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ iconType?: "chevron" | "plus";
+ }
+>(({ className, children, iconType = "chevron", ...props }, ref) => (
+
+ svg]:rotate-180 [&[data-state=open]>#icon>svg]:rotate-180",
+ className,
+ iconType === "plus"
+ ? "[&[data-state=open]&svg]:rotate-180 [&[data-state=open]>#icon>#plus]:opacity-0 [&[data-state=open]>#icon>#minus]:opacity-100 [&[data-state=closed]>#icon>#plus]:opacity-100 [&[data-state=closed]>#icon>#minus]:opacity-0"
+ : undefined
+ )}
+ {...props}
+ >
+ {children}
+ {iconType === "chevron" && (
+
+ )}
+ {iconType === "plus" && (
+
+ )}
+
+
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+));
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..25ba4c7
--- /dev/null
+++ b/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "@/lib/utils";
+import { buttonVariants } from "@/components/ui/button";
+
+const AlertDialog = AlertDialogPrimitive.Root;
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal;
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+));
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogHeader.displayName = "AlertDialogHeader";
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+AlertDialogFooter.displayName = "AlertDialogFooter";
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName;
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx
new file mode 100644
index 0000000..c223036
--- /dev/null
+++ b/src/components/ui/alert.tsx
@@ -0,0 +1,32 @@
+import { cn } from "@/lib/utils";
+import { cva, type VariantProps } from "class-variance-authority";
+
+const alertVariants = cva("rounded p-3 border", {
+ variants: {
+ variant: {
+ primary: "bg-primary/15 border-primary/15 text-primary",
+ success: "bg-success/15 border-success/15 text-success",
+ destructive: "bg-destructive/20 border-destructive/15 text-destructive/95",
+ default: "bg-default/40 border-default/40 text-gray-600",
+ },
+ },
+ defaultVariants: {
+ variant: "primary",
+ },
+});
+
+interface AlertProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Alert({ variant, className, ...props }: AlertProps) {
+ return (
+
+ );
+}
+
+export { Alert };
diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..71a5c32
--- /dev/null
+++ b/src/components/ui/breadcrumb.tsx
@@ -0,0 +1,115 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Breadcrumb = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef<"nav"> & {
+ separator?: React.ReactNode
+ }
+>(({ ...props }, ref) => )
+Breadcrumb.displayName = "Breadcrumb"
+
+const BreadcrumbList = React.forwardRef<
+ HTMLOListElement,
+ React.ComponentPropsWithoutRef<"ol">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbList.displayName = "BreadcrumbList"
+
+const BreadcrumbItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentPropsWithoutRef<"li">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbItem.displayName = "BreadcrumbItem"
+
+const BreadcrumbLink = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentPropsWithoutRef<"a"> & {
+ asChild?: boolean
+ }
+>(({ asChild, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a"
+
+ return (
+
+ )
+})
+BreadcrumbLink.displayName = "BreadcrumbLink"
+
+const BreadcrumbPage = React.forwardRef<
+ HTMLSpanElement,
+ React.ComponentPropsWithoutRef<"span">
+>(({ className, ...props }, ref) => (
+
+))
+BreadcrumbPage.displayName = "BreadcrumbPage"
+
+const BreadcrumbSeparator = ({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) => (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+)
+BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
+
+const BreadcrumbEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More
+
+)
+BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..eb38519
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,59 @@
+"use client";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+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-default dark:bg-muted text-default-foreground hover:bg-default/80",
+ primary: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ light: "bg-gray-100 text-accent-foreground hover:bg-gray-100/80",
+ },
+ 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: "primary",
+ size: "default",
+ },
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ }
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx
new file mode 100644
index 0000000..2f02434
--- /dev/null
+++ b/src/components/ui/calendar.tsx
@@ -0,0 +1,66 @@
+"use client"
+
+import * as React from "react"
+import { ChevronLeft, ChevronRight } from "lucide-react"
+import { DayPicker } from "react-day-picker"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+export type CalendarProps = React.ComponentProps
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ ,
+ IconRight: ({ ...props }) => ,
+ }}
+ {...props}
+ />
+ )
+}
+Calendar.displayName = "Calendar"
+
+export { Calendar }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..cf50855
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/src/components/ui/carousel.tsx b/src/components/ui/carousel.tsx
new file mode 100644
index 0000000..ec505d0
--- /dev/null
+++ b/src/components/ui/carousel.tsx
@@ -0,0 +1,262 @@
+"use client"
+
+import * as React from "react"
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react"
+import { ArrowLeft, ArrowRight } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+
+type CarouselApi = UseEmblaCarouselType[1]
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+type CarouselProps = {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ orientation?: "horizontal" | "vertical"
+ setApi?: (api: CarouselApi) => void
+}
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: boolean
+ canScrollNext: boolean
+} & CarouselProps
+
+const CarouselContext = React.createContext(null)
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext)
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+
+ return context
+}
+
+const Carousel = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & CarouselProps
+>(
+ (
+ {
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins
+ )
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) {
+ return
+ }
+
+ setCanScrollPrev(api.canScrollPrev())
+ setCanScrollNext(api.canScrollNext())
+ }, [])
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev()
+ }, [api])
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext()
+ }, [api])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ },
+ [scrollPrev, scrollNext]
+ )
+
+ React.useEffect(() => {
+ if (!api || !setApi) {
+ return
+ }
+
+ setApi(api)
+ }, [api, setApi])
+
+ React.useEffect(() => {
+ if (!api) {
+ return
+ }
+
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api?.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+
+
+ {children}
+
+
+ )
+ }
+)
+Carousel.displayName = "Carousel"
+
+const CarouselContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { carouselRef, orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselContent.displayName = "CarouselContent"
+
+const CarouselItem = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => {
+ const { orientation } = useCarousel()
+
+ return (
+
+ )
+})
+CarouselItem.displayName = "CarouselItem"
+
+const CarouselPrevious = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
+
+ return (
+
+
+ Previous slide
+
+ )
+})
+CarouselPrevious.displayName = "CarouselPrevious"
+
+const CarouselNext = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps
+>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
+
+ return (
+
+
+ Next slide
+
+ )
+})
+CarouselNext.displayName = "CarouselNext"
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+}
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..dbae865
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { Check } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
new file mode 100644
index 0000000..dbb2e8b
--- /dev/null
+++ b/src/components/ui/command.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import { type DialogProps } from "@radix-ui/react-dialog"
+import { Command as CommandPrimitive } from "cmdk"
+import { Search } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Dialog, DialogContent } from "@/components/ui/dialog"
+
+const Command = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Command.displayName = CommandPrimitive.displayName
+
+interface CommandDialogProps extends DialogProps {}
+
+const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const CommandInput = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+
+CommandInput.displayName = CommandPrimitive.Input.displayName
+
+const CommandList = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandList.displayName = CommandPrimitive.List.displayName
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>((props, ref) => (
+
+))
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName
+
+const CommandItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+
+CommandItem.displayName = CommandPrimitive.Item.displayName
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+CommandShortcut.displayName = "CommandShortcut"
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..5f27671
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ closeable?: boolean;
+ }
+>(({ className, closeable, children, ...props }, ref) => (
+
+
+
+ {children}
+ {closeable && (
+
+
+ Close
+
+ )}
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx
new file mode 100644
index 0000000..992c0b5
--- /dev/null
+++ b/src/components/ui/drawer.tsx
@@ -0,0 +1,80 @@
+"use client";
+import { cn } from "@/lib/utils";
+import {
+ ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+
+interface DrawerContextType {
+ isMenuOpen: boolean;
+ toggle?: () => void;
+}
+
+const DrawerContext = createContext({
+ isMenuOpen: false,
+});
+
+const DrawerContextProvider = ({ children }: { children: ReactNode }) => {
+ const toggle = useCallback(() => {
+ setDrawerState((old) => {
+ return { ...old, isMenuOpen: !old.isMenuOpen };
+ });
+ }, []);
+
+ const [drawerState, setDrawerState] = useState({
+ isMenuOpen: false,
+ toggle: toggle,
+ });
+
+ useEffect(() => {
+ const handleResize = () => {
+ const { innerWidth } = window;
+ if (innerWidth >= 992) {
+ setDrawerState({
+ isMenuOpen: false,
+ toggle: toggle,
+ });
+ }
+ };
+ handleResize();
+ window.addEventListener("resize", handleResize);
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ };
+ }, [toggle]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+const DrawerBackdrop = () => {
+ const { isMenuOpen, toggle } = useContext(DrawerContext);
+
+ return (
+
+ );
+}
+
+export {
+ DrawerContext,
+ DrawerContextProvider,
+ DrawerBackdrop,
+}
\ No newline at end of file
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..f69a0d6
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,200 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { Check, ChevronRight, Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, children, ...props }, ref) => (
+
+ {children}
+
+
+))
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, checked, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+))
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ inset?: boolean
+ }
+>(({ className, inset, ...props }, ref) => (
+
+))
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => {
+ return (
+
+ )
+}
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/src/components/ui/loading.tsx b/src/components/ui/loading.tsx
new file mode 100644
index 0000000..61ee3c1
--- /dev/null
+++ b/src/components/ui/loading.tsx
@@ -0,0 +1,9 @@
+import { LoaderCircle } from "lucide-react";
+
+export function Loading() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..04d290d
--- /dev/null
+++ b/src/components/ui/pagination.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import {
+ Pagination as NextUIPagination,
+ PaginationItem,
+} from "@nextui-org/pagination";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useMemo } from "react";
+
+interface PaginationProps {
+ basePath?: string;
+ totalPage: number;
+ currentPage: number;
+}
+
+export default function Pagination({
+ basePath = "",
+ totalPage,
+ currentPage,
+}: PaginationProps) {
+ const sp = useSearchParams();
+
+ const params = useMemo(() => {
+ const params = new URLSearchParams(sp.toString());
+ params.delete("page");
+ return params.size > 0 ? params.toString() : undefined;
+ }, [sp]);
+
+ if (totalPage <= 1) {
+ return null;
+ }
+
+ return (
+ {
+ if (value === "dots") {
+ const p = props.isBefore
+ ? currentPage - props.dotsJump
+ : currentPage + props.dotsJump;
+ return (
+
+ {props.children}
+
+ );
+ }
+
+ const disabled =
+ (value === "prev" && props.activePage === 1) ||
+ (value === "next" && props.activePage === props.total);
+
+ let p = value;
+ if (value === "prev") {
+ p = currentPage - 1;
+ } else if (value === "next") {
+ p = currentPage + 1;
+ }
+
+ if (disabled) {
+ return ;
+ }
+
+ return (
+
+ {props.children}
+
+ );
+ }}
+ />
+ );
+}
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 0000000..a0ec48b
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+
+import { cn } from "@/lib/utils"
+
+const Popover = PopoverPrimitive.Root
+
+const PopoverTrigger = PopoverPrimitive.Trigger
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+PopoverContent.displayName = PopoverPrimitive.Content.displayName
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/src/components/ui/profile-avatar.tsx b/src/components/ui/profile-avatar.tsx
new file mode 100644
index 0000000..70c6a26
--- /dev/null
+++ b/src/components/ui/profile-avatar.tsx
@@ -0,0 +1,44 @@
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { CSSProperties } from "react";
+
+export function ProfileAvatar({
+ src,
+ prefix,
+ className,
+ style,
+}: {
+ src?: string;
+ prefix?: string;
+ className?: string;
+ style?: CSSProperties;
+}) {
+ if (src) {
+ return (
+
+ );
+ }
+ return (
+
+ {prefix}
+
+ );
+}
diff --git a/src/components/ui/profile-placeholder.tsx b/src/components/ui/profile-placeholder.tsx
new file mode 100644
index 0000000..7337e9a
--- /dev/null
+++ b/src/components/ui/profile-placeholder.tsx
@@ -0,0 +1,28 @@
+import { cn } from "@/lib/utils";
+import { UserIcon } from "lucide-react";
+import { forwardRef } from "react";
+
+export interface ProfilePlaceholderProps
+ extends React.InputHTMLAttributes {
+ iconClass?: string;
+}
+
+export const ProfilePlaceholder = forwardRef<
+ HTMLDivElement,
+ ProfilePlaceholderProps
+>(({ className, iconClass, ...props }, ref) => {
+ return (
+
+
+
+ );
+});
+
+ProfilePlaceholder.displayName = "ProfilePlaceholder";
diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx
new file mode 100644
index 0000000..29469f2
--- /dev/null
+++ b/src/components/ui/progress.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import * as React from "react";
+import * as ProgressPrimitive from "@radix-ui/react-progress";
+
+import { cn } from "@/lib/utils";
+
+const Progress = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ indicatorClass?: string;
+ }
+>(({ className, value, indicatorClass, ...props }, ref) => (
+
+
+
+));
+Progress.displayName = ProgressPrimitive.Root.displayName;
+
+export { Progress };
diff --git a/src/components/ui/radio-button.tsx b/src/components/ui/radio-button.tsx
new file mode 100644
index 0000000..a7aa441
--- /dev/null
+++ b/src/components/ui/radio-button.tsx
@@ -0,0 +1,30 @@
+import { cn } from "@/lib/utils";
+import { Circle } from "lucide-react";
+import { forwardRef } from "react";
+
+export interface RadioButtonProps
+ extends React.InputHTMLAttributes {
+ checked: boolean;
+}
+
+export const RadioButton = forwardRef(
+ ({ checked, className, ...props }, ref) => {
+ return (
+
+
+
+ );
+ }
+);
+
+RadioButton.displayName = "RadioButton";
diff --git a/src/components/ui/radio-group.tsx b/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..e9bde17
--- /dev/null
+++ b/src/components/ui/radio-group.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import * as React from "react"
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+import { Circle } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ return (
+
+
+
+
+
+ )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }
diff --git a/src/components/ui/rating.tsx b/src/components/ui/rating.tsx
new file mode 100644
index 0000000..cb49ce5
--- /dev/null
+++ b/src/components/ui/rating.tsx
@@ -0,0 +1,92 @@
+import { cn } from "@/lib/utils";
+import { Star } from "lucide-react";
+
+interface RatingProps {
+ rating: number;
+ size?: "lg" | "sm" | "default";
+}
+
+const stars = [1, 2, 3, 4, 5];
+
+function Rating({ rating, size = "default" }: RatingProps) {
+ let iconSize = 18;
+
+ switch (size) {
+ case "lg":
+ iconSize = 20;
+ break;
+ case "sm":
+ iconSize = 16;
+ break;
+ case "default":
+ iconSize = 18;
+ break;
+ }
+
+ const defaultColor = cn(
+ "stroke-slate-300 text-slate-300 dark:stroke-slate-500 dark:text-slate-500"
+ );
+ const activeColor = cn("stroke-warning text-warning");
+
+ return (
+
+ {stars.map((e, i) => {
+ if (rating >= e) {
+ return (
+
+ );
+ }
+
+ const scale = e - rating;
+
+ if (scale < 1) {
+ let width = 1 - scale;
+ if (width < 0.4) {
+ width += 0.05;
+ } else if (width > 0.5) {
+ width -= 0.05;
+ }
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export default Rating;
diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..0b4a48d
--- /dev/null
+++ b/src/components/ui/scroll-area.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+const ScrollArea = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+ {children}
+
+
+
+
+))
+ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
+
+const ScrollBar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "vertical", ...props }, ref) => (
+
+
+
+))
+ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
+
+export { ScrollArea, ScrollBar }
diff --git a/src/components/ui/scroll-to-top.tsx b/src/components/ui/scroll-to-top.tsx
new file mode 100644
index 0000000..fd1e3c3
--- /dev/null
+++ b/src/components/ui/scroll-to-top.tsx
@@ -0,0 +1,10 @@
+"use client";
+
+import { useEffect } from "react";
+
+export function ScrollToTop() {
+ useEffect(() => {
+ window.scroll({ top: 0 });
+ }, []);
+ return <>>;
+}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..8ef7dba
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import { Check, ChevronDown, ChevronUp } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef & {
+ hideIndicator?: boolean;
+ }
+>(({ className, children, hideIndicator, ...props }, ref) => (
+
+ {!hideIndicator && (
+