import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useRouter } from 'next/router'; import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr'; import CourseDetails from '@/components/content/courses/CourseDetails'; import VideoLesson from '@/components/content/courses/VideoLesson'; import DocumentLesson from '@/components/content/courses/DocumentLesson'; import CombinedLesson from '@/components/content/courses/CombinedLesson'; import CourseSidebar from '@/components/content/courses/CourseSidebar'; import { useNDKContext } from '@/context/NDKContext'; import { useSession } from 'next-auth/react'; import { nip19 } from 'nostr-tools'; import { useToast } from '@/hooks/useToast'; import { ProgressSpinner } from 'primereact/progressspinner'; import { useDecryptContent } from '@/hooks/encryption/useDecryptContent'; import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; import appConfig from '@/config/appConfig'; import useWindowWidth from '@/hooks/useWindowWidth'; import MenuTab from '@/components/menutab/MenuTab'; import { Tag } from 'primereact/tag'; import MarkdownDisplay from '@/components/markdown/MarkdownDisplay'; const useCourseData = (ndk, fetchAuthor, router) => { const [course, setCourse] = useState(null); const [lessonIds, setLessonIds] = useState([]); const [paidCourse, setPaidCourse] = useState(null); const [loading, setLoading] = useState(true); const { showToast } = useToast(); useEffect(() => { if (!router.isReady) return; const { slug } = router.query; let id; const fetchCourseId = async () => { if (slug.includes('naddr')) { const { data } = nip19.decode(slug); if (!data?.identifier) { showToast('error', 'Error', 'Resource not found'); return null; } return data.identifier; } else { return slug; } }; const fetchCourse = async courseId => { try { await ndk.connect(); const event = await ndk.fetchEvent({ '#d': [courseId] }); if (!event) return null; const author = await fetchAuthor(event.pubkey); const lessonIds = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1].split(':')[2]); const parsedCourse = { ...parseCourseEvent(event), author }; return { parsedCourse, lessonIds }; } catch (error) { console.error('Error fetching event:', error); return null; } }; const initializeCourse = async () => { setLoading(true); id = await fetchCourseId(); if (!id) { setLoading(false); return; } const courseData = await fetchCourse(id); if (courseData) { const { parsedCourse, lessonIds } = courseData; setCourse(parsedCourse); setLessonIds(lessonIds); setPaidCourse(parsedCourse.price && parsedCourse.price > 0); } setLoading(false); }; initializeCourse(); }, [router.isReady, router.query, ndk, fetchAuthor, showToast]); return { course, lessonIds, paidCourse, loading }; }; const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { const [lessons, setLessons] = useState([]); const [uniqueLessons, setUniqueLessons] = useState([]); const { showToast } = useToast(); useEffect(() => { if (lessonIds.length > 0 && pubkey) { const fetchLessons = async () => { try { await ndk.connect(); // Create a single filter with all lesson IDs to avoid multiple calls const filter = { '#d': lessonIds, kinds: [30023, 30402], authors: [pubkey], }; const events = await ndk.fetchEvents(filter); const newLessons = []; // Process events (no need to check for duplicates here) for (const event of events) { const author = await fetchAuthor(event.pubkey); const parsedLesson = { ...parseEvent(event), author }; newLessons.push(parsedLesson); } setLessons(newLessons); } catch (error) { console.error('Error fetching events:', error); } }; fetchLessons(); } }, [lessonIds, ndk, fetchAuthor, pubkey]); // Keep this deduplication logic using Map useEffect(() => { const newUniqueLessons = Array.from( new Map(lessons.map(lesson => [lesson.id, lesson])).values() ); setUniqueLessons(newUniqueLessons); }, [lessons]); return { lessons, uniqueLessons, setLessons }; }; const useDecryption = (session, paidCourse, course, lessons, setLessons, router) => { const [decryptedLessonIds, setDecryptedLessonIds] = useState({}); const [loading, setLoading] = useState(false); const { decryptContent } = useDecryptContent(); const processingRef = useRef(false); const lastLessonIdRef = useRef(null); // Get the current active lesson const currentLessonIndex = router.query.active ? parseInt(router.query.active, 10) : 0; const currentLesson = lessons.length > 0 ? lessons[currentLessonIndex] : null; const currentLessonId = currentLesson?.id; // Check if the current lesson has been decrypted const isCurrentLessonDecrypted = !paidCourse || (currentLessonId && decryptedLessonIds[currentLessonId]); // Check user access const hasAccess = useMemo(() => { if (!session?.user || !paidCourse || !course) return false; return ( session.user.purchased?.some(purchase => purchase.courseId === course?.d) || session.user?.role?.subscribed || session.user?.pubkey === course?.pubkey ); }, [session, paidCourse, course]); // Simplified decrypt function const decryptCurrentLesson = useCallback(async () => { if (!currentLesson || !hasAccess || !paidCourse) return; if (processingRef.current) return; if (decryptedLessonIds[currentLesson.id]) return; if (!currentLesson.content) return; try { processingRef.current = true; setLoading(true); // Add safety timeout to prevent infinite processing const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Decryption timeout')), 10000) ); // Race between decryption and timeout const decryptedContent = await Promise.race([ decryptContent(currentLesson.content), timeoutPromise ]); if (!decryptedContent) { return; } // Update the lessons array with decrypted content const updatedLessons = lessons.map(lesson => lesson.id === currentLesson.id ? { ...lesson, content: decryptedContent } : lesson ); setLessons(updatedLessons); // Mark this lesson as decrypted setDecryptedLessonIds(prev => ({ ...prev, [currentLesson.id]: true })); } catch (error) { // Silent error handling to prevent UI disruption } finally { setLoading(false); processingRef.current = false; } }, [currentLesson, hasAccess, paidCourse, decryptContent, lessons, setLessons, decryptedLessonIds]); // Run decryption when lesson changes useEffect(() => { if (!currentLessonId) return; // Skip if the lesson hasn't changed if (lastLessonIdRef.current === currentLessonId) return; // Update the last processed lesson id lastLessonIdRef.current = currentLessonId; if (hasAccess && paidCourse && !decryptedLessonIds[currentLessonId]) { decryptCurrentLesson(); } }, [currentLessonId, hasAccess, paidCourse, decryptedLessonIds, decryptCurrentLesson]); return { decryptionPerformed: isCurrentLessonDecrypted, loading, decryptedLessonIds }; }; const Course = () => { const router = useRouter(); const { ndk, addSigner } = useNDKContext(); const { data: session, update } = useSession(); const { showToast } = useToast(); const [activeIndex, setActiveIndex] = useState(0); const [completedLessons, setCompletedLessons] = useState([]); const [nAddresses, setNAddresses] = useState({}); const [nsec, setNsec] = useState(null); const [npub, setNpub] = useState(null); const [sidebarVisible, setSidebarVisible] = useState(false); const [nAddress, setNAddress] = useState(null); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 968; const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab const navbarHeight = 60; // Match the height from Navbar component // Memoized function to get the tab map based on view mode const getTabMap = useMemo(() => { const baseTabMap = ['overview', 'content', 'qa']; if (isMobileView) { const mobileTabMap = [...baseTabMap]; mobileTabMap.splice(2, 0, 'lessons'); return mobileTabMap; } return baseTabMap; }, [isMobileView]); useEffect(() => { if (router.isReady && router.query.slug) { const { slug } = router.query; if (slug.includes('naddr')) { setNAddress(slug); } else { console.warn('No naddress found in slug'); showToast('error', 'Error', 'Course identifier not found in URL'); setTimeout(() => { router.push('/courses'); // Redirect to courses page }, 3000); } } }, [router.isReady, router.query.slug, showToast, router]); useEffect(() => { if (router.isReady) { const { active } = router.query; if (active !== undefined) { setActiveIndex(parseInt(active, 10)); // If we have an active lesson, switch to content tab setActiveTab('content'); } else { setActiveIndex(0); // Default to overview tab when no active parameter setActiveTab('overview'); } // Auto-open sidebar on desktop, close on mobile setSidebarVisible(!isMobileView); } }, [router.isReady, router.query, isMobileView]); const setCompleted = useCallback(lessonId => { setCompletedLessons(prev => [...prev, lessonId]); }, []); const fetchAuthor = useCallback( async pubkey => { const author = await ndk.getUser({ pubkey }); const profile = await author.fetchProfile(); const fields = await findKind0Fields(profile); return fields; }, [ndk] ); const { course, lessonIds, paidCourse, loading: courseLoading, } = useCourseData(ndk, fetchAuthor, router); const { lessons, uniqueLessons, setLessons } = useLessons( ndk, fetchAuthor, lessonIds, course?.pubkey ); const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption( session, paidCourse, course, lessons, setLessons, router ); useEffect(() => { if (uniqueLessons.length > 0) { const addresses = {}; uniqueLessons.forEach(lesson => { const addr = nip19.naddrEncode({ pubkey: lesson.pubkey, kind: lesson.kind, identifier: lesson.d, relays: appConfig.defaultRelayUrls, }); addresses[lesson.id] = addr; }); setNAddresses(addresses); } }, [uniqueLessons]); useEffect(() => { if (session?.user?.privkey) { const privkeyBuffer = Buffer.from(session.user.privkey, 'hex'); setNsec(nip19.nsecEncode(privkeyBuffer)); setNpub(null); } else if (session?.user?.pubkey) { setNsec(null); setNpub(nip19.npubEncode(session.user.pubkey)); } else { setNsec(null); setNpub(null); } }, [session]); const isAuthorized = session?.user?.role?.subscribed || session?.user?.pubkey === course?.pubkey || !paidCourse || session?.user?.purchased?.some(purchase => purchase.courseId === course?.d) const handleLessonSelect = index => { setActiveIndex(index); router.push(`/course/${router.query.slug}?active=${index}`, undefined, { shallow: true }); // On mobile, switch to content tab after selection if (isMobileView) { setActiveTab('content'); setSidebarVisible(false); } }; const handlePaymentSuccess = async response => { if (response && response?.preimage) { const updated = await update(); showToast('success', 'Payment Success', 'You have successfully purchased this course'); } else { showToast('error', 'Error', 'Failed to purchase course. Please try again.'); } }; const handlePaymentError = error => { showToast( 'error', 'Payment Error', `Failed to purchase course. Please try again. Error: ${error}` ); }; const toggleTab = (index) => { const tabName = getTabMap[index]; setActiveTab(tabName); // Only show/hide sidebar on mobile - desktop keeps sidebar visible if (isMobileView) { setSidebarVisible(tabName === 'lessons'); } }; const handleToggleSidebar = () => { setSidebarVisible(!sidebarVisible); }; // Map active tab name back to index for MenuTab const getActiveTabIndex = () => { return getTabMap.indexOf(activeTab); }; // Create tab items for MenuTab const getTabItems = () => { const items = [ { label: 'Overview', icon: 'pi pi-home', }, { label: 'Content', icon: 'pi pi-book', } ]; // Add lessons tab only on mobile if (isMobileView) { items.push({ label: 'Lessons', icon: 'pi pi-list', }); } items.push({ label: 'Comments', icon: 'pi pi-comments', }); return items; }; // Add keyboard navigation support for tabs useEffect(() => { const handleKeyDown = (e) => { if (e.key === 'ArrowRight') { const currentIndex = getActiveTabIndex(); const nextIndex = (currentIndex + 1) % getTabMap.length; toggleTab(nextIndex); } else if (e.key === 'ArrowLeft') { const currentIndex = getActiveTabIndex(); const prevIndex = (currentIndex - 1 + getTabMap.length) % getTabMap.length; toggleTab(prevIndex); } }; document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); }; }, [activeTab, getTabMap, toggleTab]); // Render the QA section (empty for now) const renderQASection = () => { return ( <div className="rounded-lg p-8 mt-4 bg-gray-800 max-mob:px-4"> <h2 className="text-xl font-bold mb-4">Comments</h2> {nAddress !== null && isAuthorized ? ( <div className="px-4 max-mob:px-0"> <ZapThreadsWrapper anchor={nAddress} user={nsec || npub || null} relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/" disable="zaps" isAuthorized={isAuthorized} /> </div> ) : ( <div className="text-center p-4 mx-4 bg-gray-800/50 rounded-lg"> <p className="text-gray-400"> Comments are only available to content purchasers, subscribers, and the content creator. </p> </div> )} </div> ); }; // Render Course Overview section const renderOverviewSection = () => { // Get isCompleted status for use in the component const isCompleted = completedLessons.length > 0; return ( <div className={`bg-gray-800 rounded-lg border border-gray-800 shadow-md ${isMobileView ? 'p-4' : 'p-6'}`}> {isMobileView && course && ( <div className="mb-2"> {/* Completed tag above image in mobile view */} {isCompleted && ( <div className="mb-2"> <Tag severity="success" value="Completed" /> </div> )} {/* Course image */} {course.image && ( <div className="w-full h-48 relative rounded-lg overflow-hidden mb-3"> <img src={course.image} alt={course.title} className="w-full h-full object-cover" /> </div> )} </div> )} <CourseDetails processedEvent={course} paidCourse={paidCourse} lessons={uniqueLessons} decryptionPerformed={decryptionPerformed} handlePaymentSuccess={handlePaymentSuccess} handlePaymentError={handlePaymentError} isMobileView={isMobileView} showCompletedTag={!isMobileView} /> </div> ); }; if (courseLoading || decryptionLoading) { return ( <div className="w-full h-full flex items-center justify-center"> <ProgressSpinner /> </div> ); } const renderLesson = lesson => { if (!lesson) return null; // Check if this specific lesson is decrypted const lessonDecrypted = !paidCourse || decryptedLessonIds[lesson.id] || false; if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) { return ( <CombinedLesson lesson={lesson} course={course} decryptionPerformed={lessonDecrypted} isPaid={paidCourse} setCompleted={setCompleted} /> ); } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { return ( <VideoLesson lesson={lesson} course={course} decryptionPerformed={lessonDecrypted} isPaid={paidCourse} setCompleted={setCompleted} /> ); } else if (lesson.type === 'document' && !lesson.topics?.includes('video')) { return ( <DocumentLesson lesson={lesson} course={course} decryptionPerformed={lessonDecrypted} isPaid={paidCourse} setCompleted={setCompleted} /> ); } }; return ( <> <div className="mx-auto px-8 max-mob:px-0 mb-12 mt-2"> {/* Tab navigation using MenuTab component */} <div className="z-10 bg-transparent" style={{ top: `${navbarHeight}px`, height: `${navbarHeight}px` }}> <MenuTab items={getTabItems()} activeIndex={getActiveTabIndex()} onTabChange={(index) => toggleTab(index)} sidebarVisible={sidebarVisible} onToggleSidebar={handleToggleSidebar} isMobileView={isMobileView} /> </div> {/* Revised layout structure to prevent content flexing */} <div className="relative mt-4"> {/* Main content area with fixed width */} <div className={`transition-all duration-500 ease-in-out ${isMobileView ? 'w-full' : 'w-full'}`} style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}> {/* Overview tab content */} <div className={`${activeTab === 'overview' ? 'block' : 'hidden'}`}> {renderOverviewSection()} </div> {/* Content tab content */} <div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}> {uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? ( <div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden"> <div key={`lesson-${uniqueLessons[activeIndex].id}`}> {renderLesson(uniqueLessons[activeIndex])} </div> </div> ) : ( <div className="text-center bg-gray-800 rounded-lg p-8"> <p>Select a lesson from the sidebar to begin learning.</p> </div> )} {course?.content && ( <div className="mt-8 bg-gray-800 rounded-lg shadow-sm"> <MarkdownDisplay content={course.content} className="p-4 rounded-lg" /> </div> )} </div> {/* QA tab content */} <div className={`${activeTab === 'qa' ? 'block' : 'hidden'}`}> {renderQASection()} </div> </div> {/* Course Sidebar - positioned absolutely on desktop when visible */} {!isMobileView ? ( <div className={`transition-all duration-500 ease-in-out ${ sidebarVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full' }`} style={{ position: 'absolute', top: '0', right: '0', width: '320px', height: '100%', zIndex: 999, overflow: 'visible', pointerEvents: sidebarVisible ? 'auto' : 'none' }} > <CourseSidebar lessons={uniqueLessons} activeIndex={activeIndex} onLessonSelect={(index) => { handleLessonSelect(index); if (isMobileView) { setActiveTab('content'); // Use the tab name directly } }} completedLessons={completedLessons} isMobileView={isMobileView} sidebarVisible={sidebarVisible} setSidebarVisible={setSidebarVisible} hideToggleButton={true} /> </div> ) : ( <div className={`flex-shrink-0 transition-all duration-300 z-[999] ${ (isMobileView && activeTab === 'lessons') ? 'ml-0 w-auto opacity-100' : 'w-0 ml-0 opacity-0 overflow-hidden' }`}> <CourseSidebar lessons={uniqueLessons} activeIndex={activeIndex} onLessonSelect={(index) => { handleLessonSelect(index); if (isMobileView) { setActiveTab('content'); // Use the tab name directly } }} completedLessons={completedLessons} isMobileView={isMobileView} onClose={() => { setSidebarVisible(false); setActiveTab('content'); }} sidebarVisible={sidebarVisible} setSidebarVisible={setSidebarVisible} hideToggleButton={true} /> </div> )} </div> </div> </> ); }; export default Course;