From 9fe9dd913da4ba9d18ade5b277d3222a88627980 Mon Sep 17 00:00:00 2001 From: adil Date: Thu, 4 Dec 2025 03:16:01 +0530 Subject: [PATCH] feat: add last watched video feature --- pnpm-workspace.yaml | 6 + .../migration.sql | 18 ++ prisma/schema.prisma | 15 ++ prisma/seed.ts | 230 +++++++++++------- src/app/api/user/last-watched/route.ts | 104 ++++++++ src/components/ContentCard.tsx | 1 + src/components/CourseView.tsx | 4 + src/components/FolderView.tsx | 29 ++- src/components/NotionRenderer.tsx | 20 +- src/components/ui/LastWatchedVideo.tsx | 94 +++++++ src/lib/lastWatchedtype.ts | 8 + src/store/atoms/currentFolder.ts | 6 + src/store/atoms/index.ts | 1 + 13 files changed, 440 insertions(+), 96 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 prisma/migrations/20251203193349_last_watched_video_table_creation/migration.sql create mode 100644 src/app/api/user/last-watched/route.ts create mode 100644 src/components/ui/LastWatchedVideo.tsx create mode 100644 src/lib/lastWatchedtype.ts create mode 100644 src/store/atoms/currentFolder.ts diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..a31e4bd01 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +onlyBuiltDependencies: + - '@prisma/client' + - '@prisma/engines' + - bcrypt + - esbuild + - prisma diff --git a/prisma/migrations/20251203193349_last_watched_video_table_creation/migration.sql b/prisma/migrations/20251203193349_last_watched_video_table_creation/migration.sql new file mode 100644 index 000000000..09beb58f1 --- /dev/null +++ b/prisma/migrations/20251203193349_last_watched_video_table_creation/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "LastWatched" ( + "userId" TEXT NOT NULL, + "courseId" INTEGER NOT NULL, + "contentId" INTEGER, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "LastWatched_pkey" PRIMARY KEY ("userId","courseId") +); + +-- AddForeignKey +ALTER TABLE "LastWatched" ADD CONSTRAINT "LastWatched_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LastWatched" ADD CONSTRAINT "LastWatched_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LastWatched" ADD CONSTRAINT "LastWatched_contentId_fkey" FOREIGN KEY ("contentId") REFERENCES "Content"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca8e4431c..3427bddd6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,7 @@ model Course { purchasedBy UserPurchases[] certificate Certificate[] certIssued Boolean @default(false) + LastWatched LastWatched[] } model UserPurchases { @@ -53,6 +54,7 @@ model Content { comments Comment[] commentsCount Int @default(0) bookmark Bookmark[] + LastWatched LastWatched[] } model CourseContent { @@ -64,6 +66,18 @@ model CourseContent { @@id([courseId, contentId]) } +model LastWatched { + userId String + user User @relation(fields: [userId], references: [id]) + courseId Int + course Course @relation(fields: [courseId], references: [id]) + contentId Int? + content Content? @relation(fields: [contentId], references: [id]) + updatedAt DateTime @updatedAt + + @@id([userId, courseId]) +} + model Certificate { id String @id @default(cuid()) slug String @default("certId") @@ -164,6 +178,7 @@ model User { solanaAddresses SolanaAddress[] @relation("UserSolanaAddresses") githubUser GitHubLink? @relation("UserGithub") bounties BountySubmission[] + LastWatched LastWatched[] } model GitHubLink { diff --git a/prisma/seed.ts b/prisma/seed.ts index 0a40fbea3..9b26fd9dd 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -59,36 +59,36 @@ async function seedCourses() { }, { id: 3, - appxCourseId: '2', - discordRoleId: '3', - title: 'test course 2', + appxCourseId: '3', + discordRoleId: '4', + title: 'test course 3', imageUrl: 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/test2.png', - description: 'test course 2', + description: 'test course 3', openToEveryone: false, - slug: 'test-course-2', + slug: 'test-course-3', }, { id: 4, - appxCourseId: '2', - discordRoleId: '3', - title: 'test course 2', + appxCourseId: '4', + discordRoleId: '5', + title: 'test course 4', imageUrl: 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/test2.png', - description: 'test course 2', + description: 'test course 4', openToEveryone: false, - slug: 'test-course-2', + slug: 'test-course-4', }, { id: 5, - appxCourseId: '2', - discordRoleId: '3', - title: 'test course 2', + appxCourseId: '5', + discordRoleId: '6', + title: 'test course 5', imageUrl: 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/test2.png', - description: 'test course 2', + description: 'test course 5', openToEveryone: false, - slug: 'test-course-2', + slug: 'test-course-5', }, ]; @@ -107,43 +107,68 @@ async function seedCourses() { } async function seedContent() { - const folderData = { - type: 'folder', - title: 'week 1', - hidden: false, - thumbnail: - 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/week-1.jpg', - commentsCount: 0, - }; - try { - const createdFolder = await db.content.create({ data: folderData }); - console.log('Created folder:', createdFolder); - const folderId = createdFolder.id; - - const contentData = [ - { - type: 'notion', - title: 'Notes for week 1', - hidden: false, - thumbnail: - 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/notes.png', - parentId: folderId, - commentsCount: 0, - }, - { - type: 'video', - title: 'test video for week 1', + //creating folders for 20 week: + const weekFolders = []; + for (let i = 1; i <= 20; i++) { + const folderData = { + type: 'folder', + title: `week ${i}`, hidden: false, - thumbnail: - 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/week-1-orientation.jpg', - parentId: folderId, + thumbnail: `https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/week-${i}.jpg`, commentsCount: 0, - }, - ]; + }; + const createdFolder = await db.content.create({ data: folderData }); + weekFolders.push(createdFolder); + console.log(`Created folder: week ${i}`); + } + + for (let i = 0; i < weekFolders.length; i++) { + const weekNum = i + 1; + const folderId = weekFolders[i].id; + + const contentData = [ + { + type: 'notion', + title: `Notes for week ${weekNum}`, + hidden: false, + thumbnail: 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/notes.png', + parentId: folderId, + commentsCount: 0, + }, + ]; + + if (weekNum === 1 || weekNum === 5 || weekNum === 6) { + contentData.push({ + type: 'video', + title: `Introduction to Week ${weekNum}`, + hidden: false, + thumbnail: 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/week-1-orientation.jpg', + parentId: folderId, + commentsCount: 0, + }); + contentData.push({ + type: 'video', + title: `Week ${weekNum} - Core Concepts`, + hidden: false, + thumbnail: 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/week-1-orientation.jpg', + parentId: folderId, + commentsCount: 0, + }); + contentData.push({ + type: 'video', + title: `Week ${weekNum} - Advanced Topics`, + hidden: false, + thumbnail: 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/images/week-1-orientation.jpg', + parentId: folderId, + commentsCount: 0, + }); + } + + await db.content.createMany({ data: contentData }); + console.log(`Created content for week ${weekNum}`); + } - const createdContent = await db.content.createMany({ data: contentData }); - console.log('Created content:', createdContent); } catch (error) { console.error('Error seeding content:', error); throw error; @@ -152,12 +177,21 @@ async function seedContent() { async function seedCourseContent() { try { - await db.courseContent.create({ - data: { - courseId: 1, - contentId: 1, - }, + // Get all folder type content (the week folders) + const weekFolders = await db.content.findMany({ + where: { type: 'folder' }, + orderBy: { id: 'asc' }, }); + + for (const folder of weekFolders) { + await db.courseContent.create({ + data: { + courseId: 1, + contentId: folder.id, + }, + }); + } + console.log(`Linked ${weekFolders.length} weeks to course 1`); } catch (error) { console.error('Error seeding course content:', error); throw error; @@ -166,13 +200,20 @@ async function seedCourseContent() { async function seedNotionMetadata() { try { - await db.notionMetadata.create({ - data: { - id: 1, - notionId: '39298af78c0f4c4ea780fd448551bad3', - contentId: 2, - }, + // Get all notion type content + const notionContent = await db.content.findMany({ + where: { type: 'notion' }, }); + + for (const content of notionContent) { + await db.notionMetadata.create({ + data: { + notionId: '39298af78c0f4c4ea780fd448551bad3', + contentId: content.id, + }, + }); + } + console.log(`Created notion metadata for ${notionContent.length} notion content items`); } catch (error) { console.error('Error seeding Notion metadata:', error); throw error; @@ -181,43 +222,50 @@ async function seedNotionMetadata() { async function seedVideoMetadata() { try { - await db.videoMetadata.create({ - data: { - id: 1, - contentId: 3, - video_1080p_mp4_1: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_1080p_mp4_2: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_1080p_mp4_3: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_1080p_mp4_4: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_1080p_1: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_1080p_2: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_1080p_3: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_1080p_4: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_720p_mp4_1: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_720p_mp4_2: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_720p_mp4_3: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_720p_mp4_4: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_720p_1: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_720p_2: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_720p_3: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_720p_4: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_360p_mp4_1: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_360p_mp4_2: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_360p_mp4_3: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_360p_mp4_4: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_360p_1: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_360p_2: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_360p_3: 'https://www.w3schools.com/html/mov_bbb.mp4', - video_360p_4: 'https://www.w3schools.com/html/mov_bbb.mp4', - slides: - 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/slides/Loops%2C+callbacks.pdf', + // Get all video type content + const videoContent = await db.content.findMany({ + where: { type: 'video' }, + }); + + for (const content of videoContent) { + await db.videoMetadata.create({ + data: { + contentId: content.id, + video_1080p_mp4_1: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_1080p_mp4_2: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_1080p_mp4_3: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_1080p_mp4_4: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_1080p_1: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_1080p_2: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_1080p_3: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_1080p_4: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_720p_mp4_1: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_720p_mp4_2: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_720p_mp4_3: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_720p_mp4_4: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_720p_1: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_720p_2: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_720p_3: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_720p_4: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_360p_mp4_1: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_360p_mp4_2: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_360p_mp4_3: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_360p_mp4_4: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_360p_1: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_360p_2: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_360p_3: 'https://www.w3schools.com/html/mov_bbb.mp4', + video_360p_4: 'https://www.w3schools.com/html/mov_bbb.mp4', + slides: + 'https://appx-recordings.s3.ap-south-1.amazonaws.com/drm/100x/slides/Loops%2C+callbacks.pdf', segments: [ { title: "Introduction", start: 0, end: 3 }, { title: "Chapter 1", start: 3, end: 7 }, { title: "Conclusion", start: 7, end: 10 } ] - }, - }); + }, + }); + } + console.log(`Created video metadata for ${videoContent.length} video content items`); } catch (error) { console.error('Error seeding video metadata:', error); throw error; diff --git a/src/app/api/user/last-watched/route.ts b/src/app/api/user/last-watched/route.ts new file mode 100644 index 000000000..3576bf2c8 --- /dev/null +++ b/src/app/api/user/last-watched/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from 'next/server'; +import db from '@/db'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { LastWatchedVideoI } from '@/lib/lastWatchedtype'; + +export async function POST(req: NextRequest) { + const session=await getServerSession(authOptions); + if(!session || !session.user){ + return NextResponse.json({},{status:401}); + } + try{ + const {contentId, courseId}=await req.json(); + if(!contentId || !courseId || typeof Number(courseId)!=='number'){ + return NextResponse.json( + {error:'contentId is missing.'}, + {status: 400} + ) + } + + const user = await db.user.findFirst({ + where: { + id:session.user.id, + }, + }); + if(!user){ + return NextResponse.json({},{status:401}); + } + + await db.lastWatched.upsert({ + where:{ + userId_courseId:{ + userId:user.id, + courseId:Number(courseId) + } + }, + update:{contentId}, + create:{ + userId:user.id, + courseId:Number(courseId), + contentId:contentId + } + }); + return NextResponse.json({},{status:200}); + } + catch(err){ + console.log(err) + return NextResponse.json({error:'server error'},{status:500}); + } +} + +export async function GET(req: NextRequest) { + const session=await getServerSession(authOptions); + const { searchParams } = new URL(req.url); + const courseId = searchParams.get('courseId'); + + if(!session || !session.user){ + return NextResponse.json({},{status:401}); + } + + if(!courseId || isNaN(Number(courseId))){ + const videoInfo:LastWatchedVideoI=null; + return NextResponse.json({videoInfo},{status:200}); + } + + try{ + const lastWatchedVideo=await db.lastWatched.findUnique({ + where:{ + userId_courseId:{ + userId:session.user.id, + courseId:parseInt(courseId, 10) + } + }, + include:{ + content:{ + select:{ + id:true, + type:true, + title:true, + parentId:true + } + } + } + }) + + if(!lastWatchedVideo || !lastWatchedVideo.content){ + const videoInfo:LastWatchedVideoI=null; + return NextResponse.json({videoInfo},{status:200}); + } + + const content=lastWatchedVideo.content; + const videoInfo:LastWatchedVideoI={ + id:content.id, + type:content.type, + title:content.title, + parentId:content.parentId + } + return NextResponse.json({videoInfo},{status:200}); + } + catch(err){ + console.log(err); + return NextResponse.json({error:'server error'},{status:500}); + } +} \ No newline at end of file diff --git a/src/components/ContentCard.tsx b/src/components/ContentCard.tsx index 6da7613ec..d1e7e6093 100644 --- a/src/components/ContentCard.tsx +++ b/src/components/ContentCard.tsx @@ -42,6 +42,7 @@ export const ContentCard = ({ + {courseContent?.folder && ( + + )} {!courseContent?.folder && courseContent?.value.type === 'notion' ? ( diff --git a/src/components/FolderView.tsx b/src/components/FolderView.tsx index 330932448..8f9d6c3be 100644 --- a/src/components/FolderView.tsx +++ b/src/components/FolderView.tsx @@ -2,8 +2,11 @@ import { useRouter } from 'next/navigation'; import { ContentCard } from './ContentCard'; import { courseContent, getFilteredContent } from '@/lib/utils'; -import { useRecoilValue } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { selectFilter } from '@/store/atoms/filterContent'; +import { useEffect } from 'react'; +import { currentFolderState } from '@/store/atoms'; +import axios from 'axios'; export const FolderView = ({ courseContent, @@ -16,6 +19,19 @@ export const FolderView = ({ }) => { const router = useRouter(); + const [currentFolderId,setCurrentFolderId]=useRecoilState(currentFolderState); + useEffect(()=>{ + if(!currentFolderId) return; + const element=document.getElementById(String(`folder-${currentFolderId}`)); + if(element){ + element.scrollIntoView({ + behavior:'smooth', + block:'center' + }); + element.focus(); + } + },[currentFolderId]) + if (!courseContent?.length) { return (
@@ -29,7 +45,7 @@ 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 currentfilter = useRecoilValue(selectFilter) as 'watched' | 'watching' | 'unwatched' | 'all';; const filteredCourseContent = getFilteredContent( courseContent, @@ -72,6 +88,15 @@ export const FolderView = ({ title={content.title} image={content.image || ''} onClick={() => { + if(content.type==="folder"){ + setCurrentFolderId(content.id); + }else if(content.type==='video'){ + axios.post('/api/user/last-watched', + { + contentId:content.id, + courseId + }); + } router.push(`${updatedRoute}/${content.id}`); }} markAsCompleted={content.markAsCompleted} diff --git a/src/components/NotionRenderer.tsx b/src/components/NotionRenderer.tsx index bed2adad1..1fc9b82f2 100644 --- a/src/components/NotionRenderer.tsx +++ b/src/components/NotionRenderer.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { NotionRenderer as NotionRendererLib } from 'react-notion-x'; // core styles shared by all of react-notion-x (required) import 'react-notion-x/src/styles.css'; @@ -36,6 +36,8 @@ export const NotionRenderer = ({ const { resolvedTheme } = useTheme(); const [data, setData] = useState(null); + const hasMarkedComplete=useRef(false); + async function main() { const res = await fetch(`/api/notion?id=${id}`); const json = await res.json(); @@ -43,10 +45,22 @@ export const NotionRenderer = ({ } useEffect(() => { - main(); + const fetchData=async()=>{ + const res=await fetch(`/api/notion?id=${id}`); + const json=await res.json(); + setData(json.recordMap); + } + fetchData(); + + const timer=setTimeout(()=>{ + if(!hasMarkedComplete.current){ + handleMarkAsCompleted(true,courseId); + hasMarkedComplete.current=true; + } + },2000); return () => { - handleMarkAsCompleted(true, courseId); + clearTimeout(timer); }; }, [id]); diff --git a/src/components/ui/LastWatchedVideo.tsx b/src/components/ui/LastWatchedVideo.tsx new file mode 100644 index 000000000..0d1a5afc3 --- /dev/null +++ b/src/components/ui/LastWatchedVideo.tsx @@ -0,0 +1,94 @@ +'use client'; +import { useEffect, useState } from "react"; +import axios from "axios"; +import { LastWatchedVideoI } from "@/lib/lastWatchedtype"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { Play } from "lucide-react"; +import { motion } from "framer-motion"; + +export function LastWatchedVideo({courseId}:{courseId:number}){ + const [loading, setLoading]=useState(true); + const [videoInfo,setVideoInfo]=useState(null); + const {data: session, status} = useSession(); + const router=useRouter(); + + useEffect(()=>{ + if(status === 'loading' || !session?.user) { + setLoading(false); + return; + } + + async function fetchVideoInfo(){ + setLoading(true); + try{ + const response=await axios.get(`/api/user/last-watched?courseId=${courseId}`); + if(response.status===200){ + setVideoInfo(response.data.videoInfo as LastWatchedVideoI) + } + else{ + setVideoInfo(null); + } + } + catch(err){ + console.error('Error fetching last watched video:', err); + setVideoInfo(null); + } + finally{ + setLoading(false); + } + } + fetchVideoInfo(); + },[courseId, session, status]); + + if(loading || status === 'loading') { + return null; + } + + if(!videoInfo) { + return null; + } + + const handleClick = () => { + const path = videoInfo.parentId + ? `/courses/${courseId}/${videoInfo.parentId}/${videoInfo.id}` + : `/courses/${courseId}/${videoInfo.id}`; + router.push(path); + }; + + return ( + + ['Enter', ' '].includes(e.key) && handleClick()} + whileHover={{ scale: 1.01 }} + whileTap={{ scale: 0.99 }} + className="group relative flex cursor-pointer items-center gap-3 rounded-xl border border-blue-500/20 bg-blue-500/5 p-3 transition-all duration-300 hover:border-blue-500/40 hover:bg-blue-500/10" + > +
+ +
+ +
+

+ Continue Watching +

+

+ {videoInfo.title} +

+
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/lib/lastWatchedtype.ts b/src/lib/lastWatchedtype.ts new file mode 100644 index 000000000..a98b10ebe --- /dev/null +++ b/src/lib/lastWatchedtype.ts @@ -0,0 +1,8 @@ +interface LastWatchedVideo{ + id:number; + type:string; + title:string; + parentId:number | null; +} + +export type LastWatchedVideoI = LastWatchedVideo | null; \ No newline at end of file diff --git a/src/store/atoms/currentFolder.ts b/src/store/atoms/currentFolder.ts new file mode 100644 index 000000000..60822e9f0 --- /dev/null +++ b/src/store/atoms/currentFolder.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const currentFolderState = atom({ + key: 'currentFolder', + default: null, +}); diff --git a/src/store/atoms/index.ts b/src/store/atoms/index.ts index bf06a82c7..e937e1310 100644 --- a/src/store/atoms/index.ts +++ b/src/store/atoms/index.ts @@ -1 +1,2 @@ export * from './courses'; +export * from './currentFolder';