diff --git a/package-lock.json b/package-lock.json index 6775b6101..161753d4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@discordjs/next": "^0.1.1-dev.1673526225-a580768.0", "@prisma/client": "^5.6.0", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -962,6 +963,34 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.0.5.tgz", + "integrity": "sha512-OrVIOcZL0tl6xibeuGt5/+UxoT2N27KCFOPjFyfXMnchxSHZ/OW7cCX2nGlIYJrbHK/fczPcFzAwvNBB6XBNMA==", + "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-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + }, + "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-arrow": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", diff --git a/package.json b/package.json index f40cf14d8..399176c77 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@discordjs/next": "^0.1.1-dev.1673526225-a580768.0", "@prisma/client": "^5.6.0", "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", diff --git a/prisma/migrations/20240316193028_add_bookmarks/migration.sql b/prisma/migrations/20240316193028_add_bookmarks/migration.sql new file mode 100644 index 000000000..cf0e6c596 --- /dev/null +++ b/prisma/migrations/20240316193028_add_bookmarks/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "VideoBookmark" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "contentId" INTEGER NOT NULL, + "courseId" INTEGER NOT NULL, + "timestamp" INTEGER NOT NULL, + "description" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VideoBookmark_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "VideoBookmark" ADD CONSTRAINT "VideoBookmark_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VideoBookmark" ADD CONSTRAINT "VideoBookmark_contentId_fkey" FOREIGN KEY ("contentId") REFERENCES "Content"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VideoBookmark" ADD CONSTRAINT "VideoBookmark_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20240317222835_add_is_pinned_comment/migration.sql b/prisma/migrations/20240317222835_add_is_pinned_comment/migration.sql new file mode 100644 index 000000000..6bf493396 --- /dev/null +++ b/prisma/migrations/20240317222835_add_is_pinned_comment/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Comment" ADD COLUMN "isPinned" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3d8fb57e3..a94efa26c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,7 @@ model Course { slug String content CourseContent[] purchasedBy UserPurchases[] + bookmarks VideoBookmark[] } model UserPurchases { @@ -49,6 +50,7 @@ model Content { notionMetadataId Int? comments Comment[] commentsCount Int @default(0) + bookmarks VideoBookmark[] } model CourseContent { @@ -126,6 +128,7 @@ model User { votes Vote[] discordConnect DiscordConnect? disableDrm Boolean @default(false) + bookmarks VideoBookmark[] } model DiscordConnect { @@ -155,6 +158,20 @@ model VideoProgress { @@unique([contentId, userId]) } +model VideoBookmark { + id Int @id @default(autoincrement()) + userId String + contentId Int + courseId Int + timestamp Int + description String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + content Content @relation(fields: [contentId], references: [id], onDelete: Cascade) + course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Comment { id Int @id @default(autoincrement()) content String diff --git a/src/actions/bookmark/index.ts b/src/actions/bookmark/index.ts new file mode 100644 index 000000000..369e674e5 --- /dev/null +++ b/src/actions/bookmark/index.ts @@ -0,0 +1,115 @@ +'use server'; +import { createSafeAction } from '@/lib/create-safe-action'; +import { + BookmarkDeleteSchema, + BookmarkSchema, + BookmarkUpdateSchema, +} from './schema'; +import { InputTypeCreateBookmark, InputTypeUpdateBookmark } from './types'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { rateLimit } from '@/lib/utils'; +import db from '@/db'; +import { revalidatePath } from 'next/cache'; + +const createBookmarkHandler = async ( + data: InputTypeCreateBookmark, +): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { error: 'Unauthorized or insufficient permissions' }; + } + + const { timestamp, contentId, description, courseId } = data; + const userId = session.user.id; + + if (!rateLimit(userId)) { + return { error: 'Rate limit exceeded. Please try again later.' }; + } + + try { + const createdBookmark = await db.videoBookmark.create({ + data: { contentId, description, userId, timestamp, courseId }, + }); + + revalidatePath(`/courses/${courseId}/bookmarks`); + + return { data: createdBookmark }; + } catch (error: any) { + return { error: error.message || 'Failed to create comment.' }; + } +}; + +const updateBookmarkHandler = async ( + data: InputTypeUpdateBookmark, +): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { error: 'Unauthorized or insufficient permissions' }; + } + + const { description, courseId, id } = data; + const userId = session.user.id; + + if (!rateLimit(userId)) { + return { error: 'Rate limit exceeded. Please try again later.' }; + } + + try { + const updatedBookmark = await db.videoBookmark.update({ + where: { id }, + data: { description, userId }, + }); + + revalidatePath(`/courses/${courseId}/bookmarks`); + + return { data: updatedBookmark }; + } catch (error: any) { + return { error: error.message || 'Failed to create comment.' }; + } +}; + +const deleteBookmarkHandler = async (data: { id: number }): Promise => { + const session = await getServerSession(authOptions); + + if (!session || !session.user) { + return { error: 'Unauthorized or insufficient permissions' }; + } + + const { id } = data; + const userId = session.user.id; + + if (!rateLimit(userId)) { + return { error: 'Rate limit exceeded. Please try again later.' }; + } + try { + const deletedBookmark = await db.videoBookmark.delete({ + where: { + id, + }, + }); + + revalidatePath(`/courses/${deletedBookmark.courseId}/bookmarks`); + + return { data: deletedBookmark }; + } catch (error: any) { + return { error: error.message || 'Failed to create comment.' }; + } +}; + +export const createBookmark = createSafeAction( + BookmarkSchema, + createBookmarkHandler, +); + +export const updateBookmark = createSafeAction( + BookmarkUpdateSchema, + updateBookmarkHandler, +); + +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..cff0c7a2e --- /dev/null +++ b/src/actions/bookmark/schema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const BookmarkSchema = z.object({ + contentId: z.number(), + timestamp: z.number(), + description: z + .string() + .min(3, 'Description must contain at least 3 characters'), + courseId: z.number(), +}); + +export const BookmarkUpdateSchema = z.object({ + description: z + .string() + .min(3, 'Description must contain at least 3 characters'), + courseId: z.number(), + id: z.number(), +}); + +export const BookmarkDeleteSchema = z.object({ + id: z.number(), +}); diff --git a/src/actions/bookmark/types.ts b/src/actions/bookmark/types.ts new file mode 100644 index 000000000..3127b5f92 --- /dev/null +++ b/src/actions/bookmark/types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { BookmarkSchema, BookmarkUpdateSchema } from './schema'; +import { ActionState } from '@/lib/create-safe-action'; +import { Content, VideoBookmark } from '@prisma/client'; + +export type InputTypeCreateBookmark = z.infer; +export type ReturnTypeCreateBookmark = ActionState< + InputTypeCreateBookmark, + VideoBookmark +>; +export type InputTypeUpdateBookmark = z.infer; +export type ReturnTypeUpdateBookmark = ActionState< + InputTypeUpdateBookmark, + VideoBookmark +>; + +export type TBookmarkWithContent = VideoBookmark & { + content: Content & { parent: Content | null }; +}; diff --git a/src/app/courses/[...courseId]/page.tsx b/src/app/courses/[...courseId]/page.tsx index bcd364bdf..179a71a7e 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,40 @@ 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'; + +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.videoBookmark.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); @@ -65,6 +91,22 @@ 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)), @@ -73,10 +115,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 ( <>