From 99dd0fab6bafeb96f917d74bec84412f98344ae5 Mon Sep 17 00:00:00 2001 From: Manash Pratim Bhuyan Date: Sat, 7 Dec 2024 01:34:31 +0530 Subject: [PATCH] feat:Added Filter Button to filter content by watched, watching & unwatched videos and optimised Admin Panel (#1486) * feat:Added Filter Button to filter out watched, watching & unwatched content * feat:Added Filter Button and enhanced admin panel * few fixes * final fixes to the new features * final fixes to the new features --------- Co-authored-by: Sargam --- README.md | 16 ++-- .../course/videoProgress/duration/route.ts | 41 ++++++++++ src/app/courses/[courseId]/layout.tsx | 12 ++- src/components/ContentCard.tsx | 40 ++++++--- src/components/CourseView.tsx | 8 +- src/components/FilterContent.tsx | 82 +++++++++++++++++++ src/components/FolderView.tsx | 30 ++++--- src/components/NotionRenderer.tsx | 13 ++- src/components/Sidebar.tsx | 56 +++++++++---- src/components/admin/AddContent.tsx | 7 +- src/components/admin/CourseContent.tsx | 26 ++++++ src/components/admin/UpdateVideoClient.tsx | 39 ++++++--- src/lib/utils.ts | 62 +++++++++++++- src/store/atoms/filterContent.ts | 6 ++ src/store/atoms/trigger.ts | 6 ++ 15 files changed, 369 insertions(+), 75 deletions(-) create mode 100644 src/app/api/course/videoProgress/duration/route.ts create mode 100644 src/components/FilterContent.tsx create mode 100644 src/store/atoms/filterContent.ts create mode 100644 src/store/atoms/trigger.ts diff --git a/README.md b/README.md index 4e1573251..1f52aa691 100644 --- a/README.md +++ b/README.md @@ -39,17 +39,11 @@ chmod +x setup.sh ```bash docker run -d \ - --name cms-db \ - --e POSTGRES_USER=myuser \ - +-e POSTGRES_USER=myuser \ -e POSTGRES_PASSWORD=mypassword \ - --e POSTGRES_DB=mydatabase \ - +-e POSTGRES_DB=mydatabase \ -p 5432:5432 \ - postgres ``` @@ -69,7 +63,7 @@ pnpm install 3. Run database migrations: ```bash -pnpm run prisma:migrate +pnpm prisma:migrate ``` 4. Generate prisma client @@ -81,13 +75,13 @@ pnpm prisma generate 5. Seed the database: ```bash -pnpm run db:seed +pnpm db:seed ``` 6. Start the development server: ```bash -pnpm run dev +pnpm dev ``` ## Usage diff --git a/src/app/api/course/videoProgress/duration/route.ts b/src/app/api/course/videoProgress/duration/route.ts new file mode 100644 index 000000000..c06edee08 --- /dev/null +++ b/src/app/api/course/videoProgress/duration/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from 'next/server'; +import db from '@/db'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { z } from 'zod'; + +const requestBodySchema = z.object({ + contentId: z.number(), + duration: z.number(), +}); + +export async function POST(req: NextRequest) { + const parseResult = requestBodySchema.safeParse(await req.json()); + + if (!parseResult.success) { + return NextResponse.json( + { error: parseResult.error.message }, + { status: 400 }, + ); + } + const { contentId, duration } = parseResult.data; + const session = await getServerSession(authOptions); + if (!session || !session?.user) { + return NextResponse.json({}, { status: 401 }); + } + + const updatedRecord = await db.videoMetadata.upsert({ + where: { + contentId: Number(contentId), + }, + create: { + contentId: Number(contentId), + duration: Number(duration), + }, + update: { + duration, + }, + }); + + return NextResponse.json(updatedRecord); +} diff --git a/src/app/courses/[courseId]/layout.tsx b/src/app/courses/[courseId]/layout.tsx index f134db9b4..df922375a 100644 --- a/src/app/courses/[courseId]/layout.tsx +++ b/src/app/courses/[courseId]/layout.tsx @@ -1,4 +1,5 @@ import { QueryParams } from '@/actions/types'; +import { FilterContent } from '@/components/FilterContent'; import { Sidebar } from '@/components/Sidebar'; import { getFullCourseContent } from '@/db/course'; import { authOptions } from '@/lib/auth'; @@ -46,10 +47,17 @@ const Layout = async ({ } const fullCourseContent = await getFullCourseContent(parseInt(courseId, 10)); - return (
- +
+
+ +
+
+ +
+
+ {children}
); diff --git a/src/components/ContentCard.tsx b/src/components/ContentCard.tsx index edc944e40..6da7613ec 100644 --- a/src/components/ContentCard.tsx +++ b/src/components/ContentCard.tsx @@ -5,7 +5,12 @@ import { formatTime } from '@/lib/utils'; import VideoThumbnail from './videothumbnail'; import CardComponent from './CardComponent'; import { motion } from 'framer-motion'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; import React from 'react'; export const ContentCard = ({ @@ -40,7 +45,9 @@ export const ContentCard = ({ onClick={onClick} tabIndex={0} role="button" - onKeyDown={(e: React.KeyboardEvent) => (['Enter', ' '].includes(e.key) && onClick())} + onKeyDown={(e: React.KeyboardEvent) => + ['Enter', ' '].includes(e.key) && onClick() + } className={`group relative flex h-fit w-full max-w-md cursor-pointer flex-col gap-2 rounded-2xl transition-all duration-300 hover:-translate-y-2`} > {markAsCompleted && ( @@ -57,7 +64,9 @@ export const ContentCard = ({
{!!videoProgressPercent && ( @@ -76,10 +85,18 @@ export const ContentCard = ({ title={title} contentId={contentId ?? 0} imageUrl="" - // imageUrl={ - // 'https://d2szwvl7yo497w.cloudfront.net/courseThumbnails/video.png' - // } + // imageUrl={ + // 'https://d2szwvl7yo497w.cloudfront.net/courseThumbnails/video.png' + // } /> + {!!videoProgressPercent && ( +
+
+
+ )}
)}
@@ -98,11 +115,12 @@ export const ContentCard = ({
- { - Array.isArray(weeklyContentTitles) && weeklyContentTitles?.length > 0 && - {weeklyContentTitles?.map((title) =>

{title}

)} -
- } + {Array.isArray(weeklyContentTitles) && + weeklyContentTitles?.length > 0 && ( + + {weeklyContentTitles?.map((title) =>

{title}

)} +
+ )} ); diff --git a/src/components/CourseView.tsx b/src/components/CourseView.tsx index 7a2ba1ea2..5cebdbb4c 100644 --- a/src/components/CourseView.tsx +++ b/src/components/CourseView.tsx @@ -51,9 +51,11 @@ export const CourseView = ({ {!courseContent?.folder && courseContent?.value.type === 'notion' ? ( - + ) : null} - {!courseContent?.folder && (contentType === 'video' || contentType === 'appx') ? ( ) : null} - {!courseContent?.folder && (contentType === 'video' || contentType === 'notion') && ( )} - {courseContent?.folder ? ( ( + (props, ref) => { + const [open, setOpen] = useState(false); + const [value, setValue] = useRecoilState(selectFilter); + + return ( + + + + + + + + + {allFilters.map((filters) => ( + { + setValue(currentValue === value ? '' : currentValue); + setOpen(false); + }} + > + + {filters.label} + + ))} + + + + + + ); + }, +); diff --git a/src/components/FolderView.tsx b/src/components/FolderView.tsx index 61839d191..15757b86a 100644 --- a/src/components/FolderView.tsx +++ b/src/components/FolderView.tsx @@ -1,8 +1,9 @@ 'use client'; import { useRouter } from 'next/navigation'; import { ContentCard } from './ContentCard'; -import { Bookmark } from '@prisma/client'; -import { CourseContentType } from '@/lib/utils'; +import { courseContent, getFilteredContent } from '@/lib/utils'; +import { useRecoilValue } from 'recoil'; +import { selectFilter } from '@/store/atoms/filterContent'; export const FolderView = ({ courseContent, @@ -11,18 +12,7 @@ export const FolderView = ({ }: { courseId: number; rest: string[]; - courseContent: { - type: CourseContentType; - title: string; - image: string; - id: number; - markAsCompleted: boolean; - percentComplete: number | null; - videoFullDuration?: number; - duration?: number; - bookmark: Bookmark | null; - weeklyContentTitles?: string[]; - }[]; + courseContent: courseContent[]; }) => { const router = useRouter(); @@ -39,16 +29,24 @@ export const FolderView = ({ } // why? because we have to reset the segments or they will be visible always after a video + const currentfilter = useRecoilValue(selectFilter); + + const filteredCourseContent = getFilteredContent( + courseContent, + currentfilter, + ); + return (
- {courseContent.map((content) => { + {filteredCourseContent.map((content) => { const videoProgressPercent = content.type === 'video' && content.videoFullDuration && content.duration ? (content.duration / content.videoFullDuration) * 100 - : 0; + : content.percentComplete || 0; + return ( { +export const NotionRenderer = ({ + id, + courseId, +}: { + id: string; + courseId: number; +}) => { const { resolvedTheme } = useTheme(); const [data, setData] = useState(null); @@ -37,6 +44,10 @@ export const NotionRenderer = ({ id }: { id: string }) => { useEffect(() => { main(); + + return () => { + handleMarkAsCompleted(true, courseId); + }; }, [id]); if (!data) { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index fcb3a08b7..545305b54 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -9,7 +9,7 @@ import { } from '@/components/ui/accordion'; import { Play, File, X, Menu } from 'lucide-react'; import { FullCourseContent } from '@/db/course'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { sidebarOpen as sidebarOpenAtom } from '@/store/atoms/sidebar'; import { useEffect, useState, useCallback, useMemo } from 'react'; import { handleMarkAsCompleted } from '@/lib/utils'; @@ -17,7 +17,8 @@ import BookmarkButton from './bookmark/BookmarkButton'; import Link from 'next/link'; import { Button } from './ui/button'; import { AnimatePresence, motion } from 'framer-motion'; - +import { FilterContent } from './FilterContent'; +import { selectFilter } from '@/store/atoms/filterContent'; const sidebarVariants = { open: { width: '100%', @@ -47,7 +48,9 @@ export function Sidebar({ >([]); const sidebarRef = useRef(null); const buttonRef = useRef(null); + const filterRef = useRef(null); const closeSidebar = () => setSidebarOpen(false); + const currentfilter = useRecoilValue(selectFilter); const findPathToContent = useCallback( ( @@ -77,7 +80,8 @@ export function Sidebar({ if ( sidebarRef.current && !sidebarRef.current.contains(event.target as Node) && - !buttonRef.current?.contains(event.target as Node) + !buttonRef.current?.contains(event.target as Node) && + !filterRef.current?.contains(event.target as Node) ) { closeSidebar(); } @@ -166,7 +170,6 @@ export function Sidebar({ (contents: FullCourseContent[]) => { return contents.map((content) => { const isActiveContent = currentActiveContentIds?.includes(content.id); - if (content.children && content.children.length > 0) { return ( } {content.type === 'notion' && }
-
{content.title}
+ {content.type === 'video' && ( + + )}
- {content.type === 'video' && ( - - )} - - + + ) ); }); }, - [currentActiveContentIds, navigateToContent], + [currentActiveContentIds, navigateToContent, currentfilter], ); const memoizedContent = useMemo( @@ -254,6 +256,10 @@ export function Sidebar({

Course Content

+