Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/bookmarks #237

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions prisma/migrations/20240316193028_add_bookmarks/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "isPinned" BOOLEAN NOT NULL DEFAULT false;
17 changes: 17 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ model Course {
slug String
content CourseContent[]
purchasedBy UserPurchases[]
bookmarks VideoBookmark[]
}

model UserPurchases {
Expand Down Expand Up @@ -49,6 +50,7 @@ model Content {
notionMetadataId Int?
comments Comment[]
commentsCount Int @default(0)
bookmarks VideoBookmark[]
}

model CourseContent {
Expand Down Expand Up @@ -126,6 +128,7 @@ model User {
votes Vote[]
discordConnect DiscordConnect?
disableDrm Boolean @default(false)
bookmarks VideoBookmark[]
}

model DiscordConnect {
Expand Down Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions src/actions/bookmark/index.ts
Original file line number Diff line number Diff line change
@@ -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<any> => {
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<any> => {
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<any> => {
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,
);
22 changes: 22 additions & 0 deletions src/actions/bookmark/schema.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
19 changes: 19 additions & 0 deletions src/actions/bookmark/types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof BookmarkSchema>;
export type ReturnTypeCreateBookmark = ActionState<
InputTypeCreateBookmark,
VideoBookmark
>;
export type InputTypeUpdateBookmark = z.infer<typeof BookmarkUpdateSchema>;
export type ReturnTypeUpdateBookmark = ActionState<
InputTypeUpdateBookmark,
VideoBookmark
>;

export type TBookmarkWithContent = VideoBookmark & {
content: Content & { parent: Content | null };
};
64 changes: 51 additions & 13 deletions src/app/courses/[...courseId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,45 @@
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';
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<TBookmarkWithContent[] | { error: string }> => {
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);

Expand Down Expand Up @@ -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 (
<BookmarkView
bookmarkData={bookmarkData}
courseId={course.id}
fullCourseContent={fullCourseContent}
/>
);
}

const courseContent = findContentById(
fullCourseContent,
rest.map((x) => parseInt(x, 10)),
Expand All @@ -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 (
<>
<CourseView
Expand Down
Loading
Loading