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 (
);
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
+
);
}
+
+function filterContent(filter: string, content: FullCourseContent) {
+ if (filter === 'all' || filter === '') {
+ return true;
+ }
+ if (filter === 'watched') {
+ return content.videoProgress?.markAsCompleted;
+ }
+ if (filter === 'watching') {
+ return (
+ content.videoProgress?.markAsCompleted === false &&
+ content.videoProgress?.duration !== null &&
+ content.videoProgress?.duration !== 0
+ );
+ }
+ if (filter === 'unwatched') {
+ return (
+ content.videoProgress?.markAsCompleted === false &&
+ content.videoProgress?.duration === 0
+ );
+ }
+}
diff --git a/src/components/admin/AddContent.tsx b/src/components/admin/AddContent.tsx
index df4af0e96..69901c56c 100644
--- a/src/components/admin/AddContent.tsx
+++ b/src/components/admin/AddContent.tsx
@@ -7,6 +7,8 @@ import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { FaDiscord } from 'react-icons/fa';
import { toast } from 'sonner';
+import { useSetRecoilState } from 'recoil';
+import { trigger } from '@/store/atoms/trigger';
import {
Dialog,
DialogContent,
@@ -33,6 +35,7 @@ export const AddContent = ({
const [metadata, setMetadata] = useState({});
const [discordChecked, setDiscordChecked] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
+ const setTrigger = useSetRecoilState(trigger);
const handleDiscordClick = () => {
setIsModalOpen(true);
@@ -98,12 +101,12 @@ export const AddContent = ({
'Content-Type': 'application/json',
},
});
- setLoading(false);
const responseData = await response.json();
-
+ setLoading(false);
if (response.status === 200) {
// handle success if needed
toast.success(responseData.message);
+ setTrigger((prev) => prev + 1); // why? trigger a re-render, this is a hack
setMetadata({});
} else {
// handle error if needed
diff --git a/src/components/admin/CourseContent.tsx b/src/components/admin/CourseContent.tsx
index 39abdd936..4b888ccf4 100644
--- a/src/components/admin/CourseContent.tsx
+++ b/src/components/admin/CourseContent.tsx
@@ -1,6 +1,10 @@
'use client';
import { useRouter } from 'next/navigation';
import { ContentCard } from '../ContentCard';
+import { RefreshCw } from 'lucide-react';
+import { useRecoilState } from 'recoil';
+import { trigger } from '@/store/atoms/trigger';
+import { useEffect } from 'react';
export const AdminCourseContent = ({
courseContent,
@@ -16,14 +20,36 @@ export const AdminCourseContent = ({
}[];
rest: string[];
}) => {
+ const [triggerRender, setTrigger] = useRecoilState(trigger);
const router = useRouter();
let updatedRoute = `/admin/content/${courseId}`;
for (let i = 0; i < rest.length; i++) {
updatedRoute += `/${rest[i]}`;
}
+ const handleClick = () => {
+ setTrigger((prev) => prev + 1); // trigger a re-render, this is a hack
+ };
+
+ useEffect(() => {
+ console.log('triggerRender', triggerRender);
+ if (triggerRender) {
+ console.log('refreshing');
+ setTimeout(() => {
+ router.refresh();
+ setTrigger(0);
+ }, 500);
+ }
+ }, [triggerRender]);
return (
+ {
+
+ Reload
+
+
+ }
+
{courseContent?.map(
(content: {
diff --git a/src/components/admin/UpdateVideoClient.tsx b/src/components/admin/UpdateVideoClient.tsx
index 92aed3dd3..9d8046a4d 100644
--- a/src/components/admin/UpdateVideoClient.tsx
+++ b/src/components/admin/UpdateVideoClient.tsx
@@ -11,6 +11,7 @@ import { FileText, Video } from 'lucide-react';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { Label } from '../ui/label';
+import { toast } from 'sonner';
export const UpdateVideoClient = ({
content,
@@ -44,10 +45,16 @@ export const UpdateVideoClient = ({
type="single"
collapsible
>
+
+
+ {content.title}
+
+
- M3U8 and MP4 Links
+
+ M3U8 and MP4 Links
@@ -122,18 +129,26 @@ export const UpdateVideoClient = ({