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 useCourseDecryption from '@/hooks/encryption/useCourseDecryption'; 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 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 } = useCourseDecryption( 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 (
Comments are only available to content purchasers, subscribers, and the content creator.
Select a lesson from the sidebar to begin learning.