diff --git a/package-lock.json b/package-lock.json index d896a95d9..209ba4471 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tooltip": "^1.0.7", "@types/bcrypt": "^5.0.2", "@types/jsonwebtoken": "^9.0.5", "axios": "^1.6.2", @@ -1590,6 +1591,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz", + "integrity": "sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-popper": "1.1.3", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-visually-hidden": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", diff --git a/package.json b/package.json index 2b0e2cf96..4e98c62fc 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tooltip": "^1.0.7", "@types/bcrypt": "^5.0.2", "@types/jsonwebtoken": "^9.0.5", "axios": "^1.6.2", diff --git a/prisma/migrations/20240319182925_test/migration.sql b/prisma/migrations/20240317222835_add_is_pinned_comment/migration.sql similarity index 100% rename from prisma/migrations/20240319182925_test/migration.sql rename to prisma/migrations/20240317222835_add_is_pinned_comment/migration.sql diff --git a/prisma/migrations/20240318200949_add_bookmark/migration.sql b/prisma/migrations/20240318200949_add_bookmark/migration.sql new file mode 100644 index 000000000..d3e8ee72c --- /dev/null +++ b/prisma/migrations/20240318200949_add_bookmark/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "Bookmark" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "contentId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "courseId" INTEGER NOT NULL, + + CONSTRAINT "Bookmark_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Bookmark_contentId_key" ON "Bookmark"("contentId"); + +-- AddForeignKey +ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_contentId_fkey" FOREIGN KEY ("contentId") REFERENCES "Content"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e0a385169..b0215f596 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ model Course { slug String content CourseContent[] purchasedBy UserPurchases[] + bookmarks Bookmark[] } model UserPurchases { @@ -49,6 +50,7 @@ model Content { notionMetadataId Int? comments Comment[] commentsCount Int @default(0) + bookmark Bookmark? } model CourseContent { @@ -126,6 +128,7 @@ model User { votes Vote[] discordConnect DiscordConnect? disableDrm Boolean @default(false) + bookmarks Bookmark[] password String? appxUserId String? appxUsername String? @@ -158,6 +161,17 @@ model VideoProgress { @@unique([contentId, userId]) } +model Bookmark { + id Int @id @default(autoincrement()) + userId String + contentId Int @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + content Content @relation(fields: [contentId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + courseId Int + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) +} + model Comment { id Int @id @default(autoincrement()) content String @@ -200,4 +214,3 @@ enum CommentType { INTRO DEFAULT } - diff --git a/src/actions/bookmark/index.ts b/src/actions/bookmark/index.ts new file mode 100644 index 000000000..d68617e00 --- /dev/null +++ b/src/actions/bookmark/index.ts @@ -0,0 +1,80 @@ +'use server'; +import { createSafeAction } from '@/lib/create-safe-action'; +import { BookmarkCreateSchema, BookmarkDeleteSchema } from './schema'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { rateLimit } from '@/lib/utils'; +import db from '@/db'; +import { + InputTypeCreateBookmark, + InputTypeDeleteBookmark, + ReturnTypeCreateBookmark, +} from './types'; +import { revalidatePath } from 'next/cache'; + +const reloadBookmarkPage = (courseId: number) => { + revalidatePath(`/courses/${courseId}/bookmarks`); +}; + +const createBookmarkHandler = async ( + data: InputTypeCreateBookmark, +): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { error: 'Unauthorized or insufficient permissions' }; + } + + const { contentId, courseId } = data; + const userId = session.user.id; + + if (!rateLimit(userId)) { + return { error: 'Rate limit exceeded. Please try again later.' }; + } + + try { + const addedBookmark = await db.bookmark.create({ + data: { contentId, userId, courseId }, + }); + reloadBookmarkPage(courseId); + return { data: addedBookmark }; + } catch (error: any) { + return { error: error.message || 'Failed to create comment.' }; + } +}; + +const deleteBookmarkHandler = async ( + data: InputTypeDeleteBookmark, +): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { error: 'Unauthorized or insufficient permissions' }; + } + + const { id, courseId } = data; + const userId = session.user.id; + + if (!rateLimit(userId)) { + return { error: 'Rate limit exceeded. Please try again later.' }; + } + + try { + const deletedBookmark = await db.bookmark.delete({ + where: { id }, + }); + reloadBookmarkPage(courseId); + return { data: deletedBookmark }; + } catch (error: any) { + return { error: error.message || 'Failed to create comment.' }; + } +}; + +export const createBookmark = createSafeAction( + BookmarkCreateSchema, + createBookmarkHandler, +); +export const deleteBookmark = createSafeAction( + BookmarkDeleteSchema, + deleteBookmarkHandler, +); diff --git a/src/actions/bookmark/schema.ts b/src/actions/bookmark/schema.ts new file mode 100644 index 000000000..fa6a710da --- /dev/null +++ b/src/actions/bookmark/schema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const BookmarkCreateSchema = z.object({ + contentId: z.number(), + courseId: z.number(), +}); +export const BookmarkDeleteSchema = z.object({ + id: z.number(), + courseId: z.number(), +}); diff --git a/src/actions/bookmark/types.ts b/src/actions/bookmark/types.ts new file mode 100644 index 000000000..a06e45a2f --- /dev/null +++ b/src/actions/bookmark/types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { BookmarkCreateSchema, BookmarkDeleteSchema } from './schema'; +import { ActionState } from '@/lib/create-safe-action'; +import { Bookmark, Content } from '@prisma/client'; + +export type InputTypeCreateBookmark = z.infer; +export type ReturnTypeCreateBookmark = ActionState< + InputTypeCreateBookmark, + Bookmark +>; +export type InputTypeDeleteBookmark = z.infer; +export type ReturnTypeDeleteBookmark = ActionState< + InputTypeDeleteBookmark, + Bookmark +>; + +export type TBookmarkWithContent = Bookmark & { + content: Content & { parent?: Content | null }; +}; diff --git a/src/app/courses/[...courseId]/page.tsx b/src/app/courses/[...courseId]/page.tsx index 37bdee0f8..f8ae81198 100644 --- a/src/app/courses/[...courseId]/page.tsx +++ b/src/app/courses/[...courseId]/page.tsx @@ -1,12 +1,4 @@ -import React from 'react'; -import { Course } from '@/store/atoms'; -import { - Content, - Folder, - Video, - getCourse, - getFullCourseContent, -} from '@/db/course'; +import { Folder, Video, getCourse, getFullCourseContent } from '@/db/course'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { getPurchases } from '@/utiles/appx'; @@ -14,6 +6,12 @@ import { redirect } from 'next/navigation'; import { CourseView } from '@/components/CourseView'; import { QueryParams } from '@/actions/types'; +import { Content } from '@prisma/client'; +import { TBookmarkWithContent } from '@/actions/bookmark/types'; +import db from '@/db'; +import { rateLimit } from '@/lib/utils'; +import BookmarkView from '@/components/bookmark/BookmarkView'; + interface PurchaseType { id: number; title: string; @@ -27,6 +25,34 @@ interface PurchaseType { totalVideosWatched: number; } +const getBookmarkData = async ( + courseId: string, +): Promise => { + const session = await getServerSession(authOptions); + const userId = session.user.id; + + if (!rateLimit(userId)) { + return { error: 'Rate limit exceeded. Please try again later.' }; + } + + return await db.bookmark.findMany({ + where: { + userId, + courseId: parseInt(courseId, 10), + }, + include: { + content: { + include: { + parent: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); +}; + const checkAccess = async (courseId: string) => { const session = await getServerSession(authOptions); @@ -78,6 +104,23 @@ export default async function Course({ const fullCourseContent: Folder[] = await getFullCourseContent( parseInt(courseId, 10), ); + + if (!hasAccess) { + redirect('/api/auth/signin'); + } + + if (params.courseId[1] === 'bookmarks') { + const bookmarkData = await getBookmarkData(courseId); + + return ( + + ); + } + const courseContent = findContentById( fullCourseContent, rest.map((x) => parseInt(x, 10)), @@ -86,10 +129,6 @@ export default async function Course({ courseContent?.length === 1 ? courseContent[0]?.type : 'folder'; const nextContent = null; //await getNextVideo(Number(rest[rest.length - 1])) - if (!hasAccess) { - redirect('/api/auth/signin'); - } - return ( <> { const session = useSession(); const [sidebarOpen, setSidebarOpen] = useRecoilState(sidebarOpenAtom); const currentPath = usePathname(); + const params = useParams(); + let bookmarkPageUrl = null; + if (params.courseId && params.courseId[0]) { + bookmarkPageUrl = `/courses/${params.courseId[0]}/bookmarks`; + } + return ( <>