From 85dcda958fccc197e24a20591e0b12b64a521bc3 Mon Sep 17 00:00:00 2001 From: Nimit Haria Date: Mon, 18 Mar 2024 03:37:14 +0530 Subject: [PATCH 1/9] added Bookmark Feature --- package-lock.json | 29 ++++ package.json | 1 + .../migration.sql | 22 +++ prisma/schema.prisma | 17 +++ src/actions/bookmark/index.ts | 81 ++++++++++ src/actions/bookmark/schema.ts | 15 ++ src/actions/bookmark/types.ts | 14 ++ src/app/courses/[...courseId]/page.tsx | 49 ++++-- src/app/globals.css | 29 ++++ src/components/AddBookmarkModal.tsx | 108 ++++++++++++++ src/components/Appbar.tsx | 21 ++- src/components/BookmarkCardDropdown.tsx | 51 +++++++ src/components/BookmarkList.tsx | 97 ++++++++++++ src/components/CourseView.tsx | 7 + src/components/DeleteBookmarkModal.tsx | 83 +++++++++++ src/components/VideoPlayer2.tsx | 9 +- src/components/VideoPlayerSegment.tsx | 48 +++++- src/components/ui/alert-dialog.tsx | 141 ++++++++++++++++++ src/components/ui/badge.tsx | 36 +++++ src/components/ui/bookmark-accordion.tsx | 58 +++++++ src/components/ui/dialog.tsx | 122 +++++++++++++++ src/db/Cache.ts | 11 +- yarn.lock | 15 +- 23 files changed, 1044 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20240316193028_add_bookmarks/migration.sql create mode 100644 src/actions/bookmark/index.ts create mode 100644 src/actions/bookmark/schema.ts create mode 100644 src/actions/bookmark/types.ts create mode 100644 src/components/AddBookmarkModal.tsx create mode 100644 src/components/BookmarkCardDropdown.tsx create mode 100644 src/components/BookmarkList.tsx create mode 100644 src/components/DeleteBookmarkModal.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/bookmark-accordion.tsx create mode 100644 src/components/ui/dialog.tsx 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/schema.prisma b/prisma/schema.prisma index 62df392a3..68975e177 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..15eb4194d --- /dev/null +++ b/src/actions/bookmark/index.ts @@ -0,0 +1,81 @@ +'use server'; +import { createSafeAction } from '@/lib/create-safe-action'; +import { BookmarkDeleteSchema, BookmarkSchema } from './schema'; +import { InputTypeCreateBookmark } 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, id } = data; + const userId = session.user.id; + + if (!rateLimit(userId)) { + return { error: 'Rate limit exceeded. Please try again later.' }; + } + + try { + const createdBookmark = await db.videoBookmark.upsert({ + where: { + id, + }, + update: { + description, + }, + create: { contentId, description, userId, timestamp, courseId }, + }); + + revalidatePath(`/courses/${courseId}/bookmarks`); + + return { data: createdBookmark }; + } 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 deleteBookmark = createSafeAction( + BookmarkDeleteSchema, + deleteBookmarkHandler, +); diff --git a/src/actions/bookmark/schema.ts b/src/actions/bookmark/schema.ts new file mode 100644 index 000000000..53058e56e --- /dev/null +++ b/src/actions/bookmark/schema.ts @@ -0,0 +1,15 @@ +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(), + id: z.number().optional(), +}); + +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..abbdf9077 --- /dev/null +++ b/src/actions/bookmark/types.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { BookmarkSchema } 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 TBookmarkWithContent = VideoBookmark & { + content: Content & { parent: Content | null }; +}; diff --git a/src/app/courses/[...courseId]/page.tsx b/src/app/courses/[...courseId]/page.tsx index 261a833bc..5ef07fd2d 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,39 @@ 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'; + +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); if (!session?.user) { @@ -72,6 +97,11 @@ export default async function Course({ courseContent?.length === 1 ? courseContent[0]?.type : 'folder'; const nextContent = null; //await getNextVideo(Number(rest[rest.length - 1])) + let bookmarkData: TBookmarkWithContent[] | null | { error: string } = null; + if (params.courseId[1] === 'bookmarks') { + bookmarkData = await getBookmarkData(courseId); + } + if (!hasAccess) { redirect('/api/auth/signin'); } @@ -87,6 +117,7 @@ export default async function Course({ fullCourseContent={fullCourseContent} searchParams={searchParams} possiblePath={possiblePath} + bookmarkData={bookmarkData} /> ); diff --git a/src/app/globals.css b/src/app/globals.css index 0e52f44ae..e12de72d9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -94,3 +94,32 @@ .vjs-duration { display: block !important; } + +.video-js .video-bookmark-btn { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBjbGFzcz0ibHVjaWRlIGx1Y2lkZS1ib29rbWFyayI+PHBhdGggZD0ibTE5IDIxLTctNC03IDRWNWEyIDIgMCAwIDEgMi0yaDEwYTIgMiAwIDAgMSAyIDJ2MTZ6Ii8+PC9zdmc+"); + background-repeat: no-repeat; + background-position: center; + scale: 0.7 +} + +.video-bookmark-btn::after { + content: attr(aria-label); + position: absolute; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + padding: 6px 12px; + background-color: rgba(0, 0, 0, 0.75); + color: #fff; + border-radius: 4px; + font-size: 14px; + visibility: hidden; + opacity: 0; + transition: opacity 0.3s, visibility 0.3s; +} + +/* Tooltip text visibility */ +.video-bookmark-btn:hover::after { + visibility: visible; + opacity: 1; +} diff --git a/src/components/AddBookmarkModal.tsx b/src/components/AddBookmarkModal.tsx new file mode 100644 index 000000000..4a54a4589 --- /dev/null +++ b/src/components/AddBookmarkModal.tsx @@ -0,0 +1,108 @@ +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from './ui/label'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { Textarea } from './ui/textarea'; +import { formatTime } from '@/lib/utils'; +import { useAction } from '@/hooks/useAction'; +import { toast } from 'sonner'; +import { createBookmark } from '@/actions/bookmark'; +import { FormErrors } from './FormError'; +import { useParams } from 'next/navigation'; +import { FormEvent } from 'react'; + +interface IProps { + timestamp: number; + onClose: () => void; + open: boolean; + contentId: number; + desc?: string; + id?: number; +} + +const AddBookmarkModal = ({ + timestamp, + onClose, + open, + contentId, + desc, + id, +}: IProps) => { + const params = useParams(); + const courseId = params.courseId[0]; + + const { execute, fieldErrors } = useAction(createBookmark, { + onSuccess: () => { + toast('Bookmark added', { duration: 3000 }); + onClose(); + }, + onError: (error) => { + toast.error(error); + }, + }); + + const handleFormSubmit = (e: FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.target as HTMLFormElement); + const description = formData.get('description') as string; + + execute({ + contentId, + timestamp, + description, + courseId: parseInt(courseId, 10), + ...(id !== undefined && { id }), + }); + }; + + return ( + + + + Add bookmark + +
+
+ + +
+
+ +