From f526913f309e4f8c6ce1154b76172ea3b34f7493 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 11 May 2025 11:14:54 -0500 Subject: [PATCH 01/17] course decryption logic in its own hook --- src/hooks/encryption/useCourseDecryption.js | 151 ++++++++++++++++++++ src/pages/course/[slug]/index.js | 150 +------------------ 2 files changed, 153 insertions(+), 148 deletions(-) create mode 100644 src/hooks/encryption/useCourseDecryption.js diff --git a/src/hooks/encryption/useCourseDecryption.js b/src/hooks/encryption/useCourseDecryption.js new file mode 100644 index 0000000..6e9046f --- /dev/null +++ b/src/hooks/encryption/useCourseDecryption.js @@ -0,0 +1,151 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useDecryptContent } from './useDecryptContent'; + +const useCourseDecryption = (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); + const retryCountRef = useRef({}); + const MAX_RETRIES = 3; + + // 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]); + + // Reset retry count when lesson changes + useEffect(() => { + if (currentLessonId && lastLessonIdRef.current !== currentLessonId) { + retryCountRef.current[currentLessonId] = 0; + } + }, [currentLessonId]); + + // 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; + + // Check retry count + if (!retryCountRef.current[currentLesson.id]) { + retryCountRef.current[currentLesson.id] = 0; + } + + // Limit maximum retries + if (retryCountRef.current[currentLesson.id] >= MAX_RETRIES) { + return; + } + + // Increment retry count + retryCountRef.current[currentLesson.id]++; + + try { + processingRef.current = true; + setLoading(true); + + // Start the decryption process + const decryptionPromise = decryptContent(currentLesson.content); + + // Add safety timeout to prevent infinite processing + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + // Cancel the in-flight request when timeout occurs + if (decryptionPromise.cancel) { + decryptionPromise.cancel(); + } + reject(new Error('Decryption timeout')); + }, 10000); + }); + + // Use a separate try-catch for the race + let decryptedContent; + try { + // Race between decryption and timeout + decryptedContent = await Promise.race([ + decryptionPromise, + timeoutPromise + ]); + + // Clear the timeout if decryption wins + clearTimeout(timeoutId); + } catch (error) { + // If timeout or network error, schedule a retry + setTimeout(() => { + processingRef.current = false; + decryptCurrentLesson(); + }, 5000); + throw error; + } + + 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 + })); + + // Reset retry counter on success + retryCountRef.current[currentLesson.id] = 0; + } 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, unless it failed decryption previously + if (lastLessonIdRef.current === currentLessonId && decryptedLessonIds[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 + }; +}; + +export default useCourseDecryption; diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index b03be74..c4700e7 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -12,6 +12,7 @@ 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'; @@ -135,153 +136,6 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { 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); - const retryCountRef = useRef({}); - const MAX_RETRIES = 3; - - // 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]); - - // Reset retry count when lesson changes - useEffect(() => { - if (currentLessonId && lastLessonIdRef.current !== currentLessonId) { - retryCountRef.current[currentLessonId] = 0; - } - }, [currentLessonId]); - - // 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; - - // Check retry count - if (!retryCountRef.current[currentLesson.id]) { - retryCountRef.current[currentLesson.id] = 0; - } - - // Limit maximum retries - if (retryCountRef.current[currentLesson.id] >= MAX_RETRIES) { - return; - } - - // Increment retry count - retryCountRef.current[currentLesson.id]++; - - try { - processingRef.current = true; - setLoading(true); - - // Start the decryption process - const decryptionPromise = decryptContent(currentLesson.content); - - // Add safety timeout to prevent infinite processing - let timeoutId; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - // Cancel the in-flight request when timeout occurs - if (decryptionPromise.cancel) { - decryptionPromise.cancel(); - } - reject(new Error('Decryption timeout')); - }, 10000); - }); - - // Use a separate try-catch for the race - let decryptedContent; - try { - // Race between decryption and timeout - decryptedContent = await Promise.race([ - decryptionPromise, - timeoutPromise - ]); - - // Clear the timeout if decryption wins - clearTimeout(timeoutId); - } catch (error) { - // If timeout or network error, schedule a retry - setTimeout(() => { - processingRef.current = false; - decryptCurrentLesson(); - }, 5000); - throw error; - } - - 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 - })); - - // Reset retry counter on success - retryCountRef.current[currentLesson.id] = 0; - } 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, unless it failed decryption previously - if (lastLessonIdRef.current === currentLessonId && decryptedLessonIds[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(); @@ -371,7 +225,7 @@ const Course = () => { course?.pubkey ); - const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption( + const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useCourseDecryption( session, paidCourse, course, From 045418397c2670ea5c27a33fa6ef735d910fca5a Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 11 May 2025 12:46:25 -0500 Subject: [PATCH 02/17] extract course logic into reusable hooks --- src/config/appConfig.js | 3 +- src/hooks/courses/index.js | 9 ++ src/hooks/courses/useCoursePayment.js | 67 ++++++++++++ src/hooks/courses/useCourseTabs.js | 141 ++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/hooks/courses/index.js create mode 100644 src/hooks/courses/useCoursePayment.js create mode 100644 src/hooks/courses/useCourseTabs.js diff --git a/src/config/appConfig.js b/src/config/appConfig.js index a8325ba..4363377 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,7 +11,8 @@ const appConfig = { ], authorPubkeys: [ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', - 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' + 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345', + '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4' ], customLightningAddresses: [ { diff --git a/src/hooks/courses/index.js b/src/hooks/courses/index.js new file mode 100644 index 0000000..9f3ab6c --- /dev/null +++ b/src/hooks/courses/index.js @@ -0,0 +1,9 @@ +import useCourseDecryption from '../encryption/useCourseDecryption'; +import useCourseTabs from './useCourseTabs'; +import useCoursePayment from './useCoursePayment'; + +export { + useCourseDecryption, + useCourseTabs, + useCoursePayment +}; \ No newline at end of file diff --git a/src/hooks/courses/useCoursePayment.js b/src/hooks/courses/useCoursePayment.js new file mode 100644 index 0000000..4743cf1 --- /dev/null +++ b/src/hooks/courses/useCoursePayment.js @@ -0,0 +1,67 @@ +import { useCallback, useMemo } from 'react'; +import { useToast } from '../useToast'; +import { useSession } from 'next-auth/react'; + +/** + * Hook to handle course payment processing and authorization + * @param {Object} course - The course object + * @returns {Object} Payment handling utilities and authorization state + */ +const useCoursePayment = (course) => { + const { data: session, update } = useSession(); + const { showToast } = useToast(); + + // Determine if course requires payment + const isPaidCourse = useMemo(() => { + return course?.price && course.price > 0; + }, [course]); + + // Check if user is authorized to access the course + const isAuthorized = useMemo(() => { + if (!session?.user || !course) return !isPaidCourse; // Free courses are always authorized + + return ( + // User is subscribed + session.user.role?.subscribed || + // User is the creator of the course + session.user.pubkey === course.pubkey || + // Course is free + !isPaidCourse || + // User has purchased this specific course + session.user.purchased?.some(purchase => purchase.courseId === course.d) + ); + }, [session, course, isPaidCourse]); + + // Handler for successful payment + const handlePaymentSuccess = useCallback(async (response) => { + if (response && response?.preimage) { + // Update session to reflect purchase + const updated = await update(); + showToast('success', 'Payment Success', 'You have successfully purchased this course'); + return true; + } else { + showToast('error', 'Error', 'Failed to purchase course. Please try again.'); + return false; + } + }, [update, showToast]); + + // Handler for payment errors + const handlePaymentError = useCallback((error) => { + showToast( + 'error', + 'Payment Error', + `Failed to purchase course. Please try again. Error: ${error}` + ); + return false; + }, [showToast]); + + return { + isPaidCourse, + isAuthorized, + handlePaymentSuccess, + handlePaymentError, + session + }; +}; + +export default useCoursePayment; \ No newline at end of file diff --git a/src/hooks/courses/useCourseTabs.js b/src/hooks/courses/useCourseTabs.js new file mode 100644 index 0000000..0714aad --- /dev/null +++ b/src/hooks/courses/useCourseTabs.js @@ -0,0 +1,141 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useRouter } from 'next/router'; +import useWindowWidth from '../useWindowWidth'; + +/** + * Hook to manage course tabs, navigation, and sidebar visibility + * @param {Object} options - Configuration options + * @param {Array} options.tabMap - Optional custom tab map to use + * @param {boolean} options.initialSidebarVisible - Initial sidebar visibility state + * @returns {Object} Tab management utilities and state + */ +const useCourseTabs = (options = {}) => { + const router = useRouter(); + const windowWidth = useWindowWidth(); + const isMobileView = windowWidth <= 968; + + // Tab management state + const [activeTab, setActiveTab] = useState('overview'); + const [sidebarVisible, setSidebarVisible] = useState( + options.initialSidebarVisible !== undefined ? options.initialSidebarVisible : !isMobileView + ); + + // Get tab map based on view mode + const tabMap = useMemo(() => { + const baseTabMap = options.tabMap || ['overview', 'content', 'qa']; + if (isMobileView) { + const mobileTabMap = [...baseTabMap]; + // Insert lessons tab before qa in mobile view + if (!mobileTabMap.includes('lessons')) { + mobileTabMap.splice(2, 0, 'lessons'); + } + return mobileTabMap; + } + return baseTabMap; + }, [isMobileView, options.tabMap]); + + // Update tabs and sidebar based on router query + useEffect(() => { + if (router.isReady) { + const { active } = router.query; + if (active !== undefined) { + // If we have an active lesson, switch to content tab + setActiveTab('content'); + } else { + // 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]); + + // Get active tab index + const getActiveTabIndex = useCallback(() => { + return tabMap.indexOf(activeTab); + }, [activeTab, tabMap]); + + // Toggle between tabs + const toggleTab = useCallback((indexOrName) => { + const tabName = typeof indexOrName === 'number' + ? tabMap[indexOrName] + : indexOrName; + + setActiveTab(tabName); + + // Only show/hide sidebar on mobile - desktop keeps sidebar visible + if (isMobileView) { + setSidebarVisible(tabName === 'lessons'); + } + }, [tabMap, isMobileView]); + + // Toggle sidebar visibility + const toggleSidebar = useCallback(() => { + setSidebarVisible(prev => !prev); + }, []); + + // Generate tab items for MenuTab component + const getTabItems = useCallback(() => { + 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; + }, [isMobileView]); + + // Setup keyboard navigation for tabs + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'ArrowRight') { + const currentIndex = getActiveTabIndex(); + const nextIndex = (currentIndex + 1) % tabMap.length; + toggleTab(nextIndex); + } else if (e.key === 'ArrowLeft') { + const currentIndex = getActiveTabIndex(); + const prevIndex = (currentIndex - 1 + tabMap.length) % tabMap.length; + toggleTab(prevIndex); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [getActiveTabIndex, tabMap, toggleTab]); + + return { + activeTab, + setActiveTab, + sidebarVisible, + setSidebarVisible, + isMobileView, + toggleTab, + toggleSidebar, + getActiveTabIndex, + getTabItems, + tabMap + }; +}; + +export default useCourseTabs; \ No newline at end of file From 51cd1e4d97ddf1a2175eed49d0095e04c43d559b Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 11 May 2025 12:58:37 -0500 Subject: [PATCH 03/17] useCourseData into reusable hooks --- src/hooks/courses/index.js | 6 ++- src/hooks/courses/useCourseData.js | 79 ++++++++++++++++++++++++++++++ src/hooks/courses/useLessons.js | 61 +++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/hooks/courses/useCourseData.js create mode 100644 src/hooks/courses/useLessons.js diff --git a/src/hooks/courses/index.js b/src/hooks/courses/index.js index 9f3ab6c..f6438e0 100644 --- a/src/hooks/courses/index.js +++ b/src/hooks/courses/index.js @@ -1,9 +1,13 @@ import useCourseDecryption from '../encryption/useCourseDecryption'; import useCourseTabs from './useCourseTabs'; import useCoursePayment from './useCoursePayment'; +import useCourseData from './useCourseData'; +import useLessons from './useLessons'; export { useCourseDecryption, useCourseTabs, - useCoursePayment + useCoursePayment, + useCourseData, + useLessons }; \ No newline at end of file diff --git a/src/hooks/courses/useCourseData.js b/src/hooks/courses/useCourseData.js new file mode 100644 index 0000000..0140066 --- /dev/null +++ b/src/hooks/courses/useCourseData.js @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { parseCourseEvent } from '@/utils/nostr'; +import { nip19 } from 'nostr-tools'; +import { useToast } from '../useToast'; + +/** + * Hook to fetch and manage course data + * @param {Object} ndk - NDK instance for Nostr data fetching + * @param {Function} fetchAuthor - Function to fetch author data + * @param {Object} router - Next.js router instance + * @returns {Object} Course data and related state + */ +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; + + 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); + const 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 }; +}; + +export default useCourseData; \ No newline at end of file diff --git a/src/hooks/courses/useLessons.js b/src/hooks/courses/useLessons.js new file mode 100644 index 0000000..2c80706 --- /dev/null +++ b/src/hooks/courses/useLessons.js @@ -0,0 +1,61 @@ +import { useState, useEffect } from 'react'; +import { parseEvent } from '@/utils/nostr'; + +/** + * Hook to fetch and manage lesson data for a course + * @param {Object} ndk - NDK instance for Nostr data fetching + * @param {Function} fetchAuthor - Function to fetch author data + * @param {Array} lessonIds - Array of lesson IDs to fetch + * @param {String} pubkey - Public key of the course author + * @returns {Object} Lesson data and state + */ +const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { + const [lessons, setLessons] = useState([]); + const [uniqueLessons, setUniqueLessons] = useState([]); + + // Fetch lessons when IDs or pubkey change + 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 + 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]); + + // Deduplicate lessons + useEffect(() => { + const newUniqueLessons = Array.from( + new Map(lessons.map(lesson => [lesson.id, lesson])).values() + ); + setUniqueLessons(newUniqueLessons); + }, [lessons]); + + return { lessons, uniqueLessons, setLessons }; +}; + +export default useLessons; \ No newline at end of file From 027bf28e2f34a5312f0123964cf756649cef8877 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 11 May 2025 14:07:55 -0500 Subject: [PATCH 04/17] improve course page architecture with custom hooks and components --- .../courses/{ => details}/CourseDetails.js | 2 +- .../{ => details}/DesktopCourseDetails.js | 0 .../{ => details}/DraftCourseDetails.js | 0 .../{ => details}/DraftCourseLesson.js | 0 .../{ => details}/MobileCourseDetails.js | 0 .../courses/{ => layout}/CourseHeader.js | 0 .../courses/{ => layout}/CourseSidebar.js | 0 .../courses/{ => lessons}/CombinedLesson.js | 0 .../courses/{ => lessons}/CourseLesson.js | 0 .../courses/{ => lessons}/DocumentLesson.js | 0 .../courses/{ => lessons}/VideoLesson.js | 0 .../content/courses/tabs/CourseContent.js | 82 ++++ .../content/courses/tabs/CourseOverview.js | 58 +++ .../content/courses/tabs/CourseQA.js | 32 ++ src/components/navbar/Navbar.js | 2 +- src/hooks/courses/useCourseData.js | 11 +- src/hooks/courses/useCourseNavigation.js | 142 ++++++ src/hooks/courses/useLessons.js | 4 +- src/pages/course/[slug]/draft/index.js | 4 +- src/pages/course/[slug]/index.js | 437 +++--------------- 20 files changed, 384 insertions(+), 390 deletions(-) rename src/components/content/courses/{ => details}/CourseDetails.js (98%) rename src/components/content/courses/{ => details}/DesktopCourseDetails.js (100%) rename src/components/content/courses/{ => details}/DraftCourseDetails.js (100%) rename src/components/content/courses/{ => details}/DraftCourseLesson.js (100%) rename src/components/content/courses/{ => details}/MobileCourseDetails.js (100%) rename src/components/content/courses/{ => layout}/CourseHeader.js (100%) rename src/components/content/courses/{ => layout}/CourseSidebar.js (100%) rename src/components/content/courses/{ => lessons}/CombinedLesson.js (100%) rename src/components/content/courses/{ => lessons}/CourseLesson.js (100%) rename src/components/content/courses/{ => lessons}/DocumentLesson.js (100%) rename src/components/content/courses/{ => lessons}/VideoLesson.js (100%) create mode 100644 src/components/content/courses/tabs/CourseContent.js create mode 100644 src/components/content/courses/tabs/CourseOverview.js create mode 100644 src/components/content/courses/tabs/CourseQA.js create mode 100644 src/hooks/courses/useCourseNavigation.js diff --git a/src/components/content/courses/CourseDetails.js b/src/components/content/courses/details/CourseDetails.js similarity index 98% rename from src/components/content/courses/CourseDetails.js rename to src/components/content/courses/details/CourseDetails.js index d15a11c..e1c3c83 100644 --- a/src/components/content/courses/CourseDetails.js +++ b/src/components/content/courses/details/CourseDetails.js @@ -19,7 +19,7 @@ import { ProgressSpinner } from 'primereact/progressspinner'; import { Toast } from 'primereact/toast'; // Import the desktop and mobile components -import DesktopCourseDetails from './DesktopCourseDetails'; +import DesktopCourseDetails from '@/components/content/courses/details/DesktopCourseDetails'; import MobileCourseDetails from './MobileCourseDetails'; export default function CourseDetails({ diff --git a/src/components/content/courses/DesktopCourseDetails.js b/src/components/content/courses/details/DesktopCourseDetails.js similarity index 100% rename from src/components/content/courses/DesktopCourseDetails.js rename to src/components/content/courses/details/DesktopCourseDetails.js diff --git a/src/components/content/courses/DraftCourseDetails.js b/src/components/content/courses/details/DraftCourseDetails.js similarity index 100% rename from src/components/content/courses/DraftCourseDetails.js rename to src/components/content/courses/details/DraftCourseDetails.js diff --git a/src/components/content/courses/DraftCourseLesson.js b/src/components/content/courses/details/DraftCourseLesson.js similarity index 100% rename from src/components/content/courses/DraftCourseLesson.js rename to src/components/content/courses/details/DraftCourseLesson.js diff --git a/src/components/content/courses/MobileCourseDetails.js b/src/components/content/courses/details/MobileCourseDetails.js similarity index 100% rename from src/components/content/courses/MobileCourseDetails.js rename to src/components/content/courses/details/MobileCourseDetails.js diff --git a/src/components/content/courses/CourseHeader.js b/src/components/content/courses/layout/CourseHeader.js similarity index 100% rename from src/components/content/courses/CourseHeader.js rename to src/components/content/courses/layout/CourseHeader.js diff --git a/src/components/content/courses/CourseSidebar.js b/src/components/content/courses/layout/CourseSidebar.js similarity index 100% rename from src/components/content/courses/CourseSidebar.js rename to src/components/content/courses/layout/CourseSidebar.js diff --git a/src/components/content/courses/CombinedLesson.js b/src/components/content/courses/lessons/CombinedLesson.js similarity index 100% rename from src/components/content/courses/CombinedLesson.js rename to src/components/content/courses/lessons/CombinedLesson.js diff --git a/src/components/content/courses/CourseLesson.js b/src/components/content/courses/lessons/CourseLesson.js similarity index 100% rename from src/components/content/courses/CourseLesson.js rename to src/components/content/courses/lessons/CourseLesson.js diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/lessons/DocumentLesson.js similarity index 100% rename from src/components/content/courses/DocumentLesson.js rename to src/components/content/courses/lessons/DocumentLesson.js diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/lessons/VideoLesson.js similarity index 100% rename from src/components/content/courses/VideoLesson.js rename to src/components/content/courses/lessons/VideoLesson.js diff --git a/src/components/content/courses/tabs/CourseContent.js b/src/components/content/courses/tabs/CourseContent.js new file mode 100644 index 0000000..e4e7953 --- /dev/null +++ b/src/components/content/courses/tabs/CourseContent.js @@ -0,0 +1,82 @@ +import React from 'react'; +import VideoLesson from '@/components/content/courses/lessons/VideoLesson'; +import DocumentLesson from '@/components/content/courses/lessons/DocumentLesson'; +import CombinedLesson from '@/components/content/courses/lessons/CombinedLesson'; +import MarkdownDisplay from '@/components/markdown/MarkdownDisplay'; + +/** + * Component to display course content including lessons + */ +const CourseContent = ({ + lessons, + activeIndex, + course, + paidCourse, + decryptedLessonIds, + setCompleted +}) => { + 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 ( + + ); + } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { + return ( + + ); + } else if (lesson.type === 'document' && !lesson.topics?.includes('video')) { + return ( + + ); + } + + return null; + }; + + return ( + <> + {lessons.length > 0 && lessons[activeIndex] ? ( +
+
+ {renderLesson(lessons[activeIndex])} +
+
+ ) : ( +
+

Select a lesson from the sidebar to begin learning.

+
+ )} + + {course?.content && ( +
+ +
+ )} + + ); +}; + +export default CourseContent; \ No newline at end of file diff --git a/src/components/content/courses/tabs/CourseOverview.js b/src/components/content/courses/tabs/CourseOverview.js new file mode 100644 index 0000000..67dd049 --- /dev/null +++ b/src/components/content/courses/tabs/CourseOverview.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { Tag } from 'primereact/tag'; +import CourseDetails from '../details/CourseDetails'; + +/** + * Component to display course overview with details + */ +const CourseOverview = ({ + course, + paidCourse, + lessons, + decryptionPerformed, + handlePaymentSuccess, + handlePaymentError, + isMobileView, + completedLessons +}) => { + // Determine if course is completed + const isCompleted = completedLessons.length > 0; + + return ( +
+ {isMobileView && course && ( +
+ {/* Completed tag above image in mobile view */} + {isCompleted && ( +
+ +
+ )} + + {/* Course image */} + {course.image && ( +
+ {course.title} +
+ )} +
+ )} + +
+ ); +}; + +export default CourseOverview; \ No newline at end of file diff --git a/src/components/content/courses/tabs/CourseQA.js b/src/components/content/courses/tabs/CourseQA.js new file mode 100644 index 0000000..4be5783 --- /dev/null +++ b/src/components/content/courses/tabs/CourseQA.js @@ -0,0 +1,32 @@ +import React from 'react'; +import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; + +/** + * Component to display course comments and Q&A section + */ +const CourseQA = ({ nAddress, isAuthorized, nsec, npub }) => { + return ( +
+

Comments

+ {nAddress !== null && isAuthorized ? ( +
+ +
+ ) : ( +
+

+ Comments are only available to content purchasers, subscribers, and the content creator. +

+
+ )} +
+ ); +}; + +export default CourseQA; \ No newline at end of file diff --git a/src/components/navbar/Navbar.js b/src/components/navbar/Navbar.js index 9fc60c1..344d8f1 100644 --- a/src/components/navbar/Navbar.js +++ b/src/components/navbar/Navbar.js @@ -8,7 +8,7 @@ import { useSession } from 'next-auth/react'; import 'primereact/resources/primereact.min.css'; import 'primeicons/primeicons.css'; import useWindowWidth from '@/hooks/useWindowWidth'; -import CourseHeader from '../content/courses/CourseHeader'; +import CourseHeader from '../content/courses/layout/CourseHeader'; import { useNDKContext } from '@/context/NDKContext'; import { nip19 } from 'nostr-tools'; import { parseCourseEvent } from '@/utils/nostr'; diff --git a/src/hooks/courses/useCourseData.js b/src/hooks/courses/useCourseData.js index 0140066..c6a6663 100644 --- a/src/hooks/courses/useCourseData.js +++ b/src/hooks/courses/useCourseData.js @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react'; -import { parseCourseEvent } from '@/utils/nostr'; +import { useState, useEffect } from 'react'; import { nip19 } from 'nostr-tools'; -import { useToast } from '../useToast'; +import { parseCourseEvent } from '@/utils/nostr'; +import { useToast } from '@/hooks/useToast'; /** * Hook to fetch and manage course data @@ -21,7 +21,8 @@ const useCourseData = (ndk, fetchAuthor, router) => { if (!router.isReady) return; const { slug } = router.query; - + let id; + const fetchCourseId = async () => { if (slug.includes('naddr')) { const { data } = nip19.decode(slug); @@ -54,7 +55,7 @@ const useCourseData = (ndk, fetchAuthor, router) => { const initializeCourse = async () => { setLoading(true); - const id = await fetchCourseId(); + id = await fetchCourseId(); if (!id) { setLoading(false); return; diff --git a/src/hooks/courses/useCourseNavigation.js b/src/hooks/courses/useCourseNavigation.js new file mode 100644 index 0000000..8f502b4 --- /dev/null +++ b/src/hooks/courses/useCourseNavigation.js @@ -0,0 +1,142 @@ +import { useState, useEffect, useMemo } from 'react'; + +/** + * Hook to manage course navigation and tab logic + * @param {Object} router - Next.js router instance + * @param {Boolean} isMobileView - Whether the current view is mobile + * @returns {Object} Navigation state and functions + */ +const useCourseNavigation = (router, isMobileView) => { + const [activeIndex, setActiveIndex] = useState(0); + const [sidebarVisible, setSidebarVisible] = useState(false); + const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab + + // Memoized function to get the tab map based on view mode + const tabMap = useMemo(() => { + const baseTabMap = ['overview', 'content', 'qa']; + if (isMobileView) { + const mobileTabMap = [...baseTabMap]; + mobileTabMap.splice(2, 0, 'lessons'); + return mobileTabMap; + } + return baseTabMap; + }, [isMobileView]); + + // Initialize navigation state based on 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]); + + // Function to handle lesson selection + 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); + } + }; + + // Function to toggle tab + const toggleTab = (index) => { + const tabName = tabMap[index]; + setActiveTab(tabName); + + // Only show/hide sidebar on mobile - desktop keeps sidebar visible + if (isMobileView) { + setSidebarVisible(tabName === 'lessons'); + } + }; + + // Function to toggle sidebar visibility + const toggleSidebar = () => { + setSidebarVisible(!sidebarVisible); + }; + + // Map active tab name back to index for MenuTab + const getActiveTabIndex = () => { + return tabMap.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) % tabMap.length; + toggleTab(nextIndex); + } else if (e.key === 'ArrowLeft') { + const currentIndex = getActiveTabIndex(); + const prevIndex = (currentIndex - 1 + tabMap.length) % tabMap.length; + toggleTab(prevIndex); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [activeTab, tabMap]); + + return { + activeIndex, + setActiveIndex, + activeTab, + setActiveTab, + sidebarVisible, + setSidebarVisible, + handleLessonSelect, + toggleTab, + toggleSidebar, + getActiveTabIndex, + getTabItems, + tabMap + }; +}; + +export default useCourseNavigation; \ No newline at end of file diff --git a/src/hooks/courses/useLessons.js b/src/hooks/courses/useLessons.js index 2c80706..2e98ea9 100644 --- a/src/hooks/courses/useLessons.js +++ b/src/hooks/courses/useLessons.js @@ -30,7 +30,7 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { const events = await ndk.fetchEvents(filter); const newLessons = []; - // Process events + // 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 }; @@ -47,7 +47,7 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => { } }, [lessonIds, ndk, fetchAuthor, pubkey]); - // Deduplicate lessons + // Keep this deduplication logic using Map useEffect(() => { const newUniqueLessons = Array.from( new Map(lessons.map(lesson => [lesson.id, lesson])).values() diff --git a/src/pages/course/[slug]/draft/index.js b/src/pages/course/[slug]/draft/index.js index f7d0ef1..8fd1591 100644 --- a/src/pages/course/[slug]/draft/index.js +++ b/src/pages/course/[slug]/draft/index.js @@ -2,8 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useRouter } from 'next/router'; import axios from 'axios'; import { parseEvent, findKind0Fields } from '@/utils/nostr'; -import DraftCourseDetails from '@/components/content/courses/DraftCourseDetails'; -import DraftCourseLesson from '@/components/content/courses/DraftCourseLesson'; +import DraftCourseDetails from '@/components/content/courses/details/DraftCourseDetails'; +import DraftCourseLesson from '@/components/content/courses/details/DraftCourseLesson'; import { useNDKContext } from '@/context/NDKContext'; import { useSession } from 'next-auth/react'; import { useIsAdmin } from '@/hooks/useIsAdmin'; diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index c4700e7..eb7fe99 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -1,168 +1,55 @@ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import React, { useEffect, useState, useCallback } 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 { findKind0Fields } from '@/utils/nostr'; 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'; + +// Hooks import useCourseDecryption from '@/hooks/encryption/useCourseDecryption'; -import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; -import appConfig from '@/config/appConfig'; +import useCourseData from '@/hooks/courses/useCourseData'; +import useLessons from '@/hooks/courses/useLessons'; +import useCourseNavigation from '@/hooks/courses/useCourseNavigation'; import useWindowWidth from '@/hooks/useWindowWidth'; + +// Components +import CourseSidebar from '@/components/content/courses/layout/CourseSidebar'; +import CourseContent from '@/components/content/courses/tabs/CourseContent'; +import CourseQA from '@/components/content/courses/tabs/CourseQA'; +import CourseOverview from '@/components/content/courses/tabs/CourseOverview'; 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 }; -}; +// Config +import appConfig from '@/config/appConfig'; 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]); + // Use our navigation hook + const { + activeIndex, + activeTab, + sidebarVisible, + setSidebarVisible, + handleLessonSelect, + toggleTab, + toggleSidebar, + getActiveTabIndex, + getTabItems, + } = useCourseNavigation(router, isMobileView); useEffect(() => { if (router.isReady && router.query.slug) { @@ -179,24 +66,6 @@ const Course = () => { } }, [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]); }, []); @@ -268,18 +137,7 @@ const Course = () => { 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); - } - }; + session?.user?.purchased?.some(purchase => purchase.courseId === course?.d); const handlePaymentSuccess = async response => { if (response && response?.preimage) { @@ -298,141 +156,6 @@ const Course = () => { ); }; - 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

- {nAddress !== null && isAuthorized ? ( -
- -
- ) : ( -
-

- Comments are only available to content purchasers, subscribers, and the content creator. -

-
- )} -
- ); - }; - // Render Course Overview section - const renderOverviewSection = () => { - // Get isCompleted status for use in the component - const isCompleted = completedLessons.length > 0; - - return ( -
- {isMobileView && course && ( -
- {/* Completed tag above image in mobile view */} - {isCompleted && ( -
- -
- )} - - {/* Course image */} - {course.image && ( -
- {course.title} -
- )} -
- )} - -
- ); - }; - if (courseLoading || decryptionLoading) { return (
@@ -441,45 +164,6 @@ const Course = () => { ); } - 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 ( - - ); - } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { - return ( - - ); - } else if (lesson.type === 'document' && !lesson.topics?.includes('video')) { - return ( - - ); - } - }; - return ( <>
@@ -494,45 +178,50 @@ const Course = () => { activeIndex={getActiveTabIndex()} onTabChange={(index) => toggleTab(index)} sidebarVisible={sidebarVisible} - onToggleSidebar={handleToggleSidebar} + onToggleSidebar={toggleSidebar} isMobileView={isMobileView} />
- {/* Revised layout structure to prevent content flexing */} + {/* Main content area with fixed width */}
- {/* Main content area with fixed width */}
+ {/* Overview tab content */}
- {renderOverviewSection()} +
{/* Content tab content */}
- {uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? ( -
-
- {renderLesson(uniqueLessons[activeIndex])} -
-
- ) : ( -
-

Select a lesson from the sidebar to begin learning.

-
- )} - - {course?.content && ( -
- -
- )} +
{/* QA tab content */}
- {renderQASection()} +
@@ -556,12 +245,7 @@ const Course = () => { { - handleLessonSelect(index); - if (isMobileView) { - setActiveTab('content'); // Use the tab name directly - } - }} + onLessonSelect={handleLessonSelect} completedLessons={completedLessons} isMobileView={isMobileView} sidebarVisible={sidebarVisible} @@ -577,17 +261,12 @@ const Course = () => { { - handleLessonSelect(index); - if (isMobileView) { - setActiveTab('content'); // Use the tab name directly - } - }} + onLessonSelect={handleLessonSelect} completedLessons={completedLessons} isMobileView={isMobileView} onClose={() => { setSidebarVisible(false); - setActiveTab('content'); + toggleTab(getActiveTabIndex()); }} sidebarVisible={sidebarVisible} setSidebarVisible={setSidebarVisible} From 7a805f0988ab3fd08425db0b9195f7592f9687a7 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 11 May 2025 15:43:53 -0500 Subject: [PATCH 05/17] implement generic markdown input form for content --- .../forms/combined/CombinedResourceForm.js | 14 +- .../combined/EditDraftCombinedResourceForm.js | 14 +- .../EditPublishedCombinedResourceForm.js | 31 ++--- .../course/embedded/EmbeddedDocumentForm.js | 18 +-- src/components/forms/document/DocumentForm.js | 16 +-- .../forms/document/EditDraftDocumentForm.js | 10 +- .../document/EditPublishedDocumentForm.js | 26 ++-- .../forms/video/EditPublishedVideoForm.js | 27 ++-- src/components/markdown/MarkdownEditor.js | 128 ++++++++++++++++++ 9 files changed, 193 insertions(+), 91 deletions(-) create mode 100644 src/components/markdown/MarkdownEditor.js diff --git a/src/components/forms/combined/CombinedResourceForm.js b/src/components/forms/combined/CombinedResourceForm.js index c6ea3c1..558437f 100644 --- a/src/components/forms/combined/CombinedResourceForm.js +++ b/src/components/forms/combined/CombinedResourceForm.js @@ -1,19 +1,17 @@ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; -import { useRouter } from 'next/router'; import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; import { InputNumber } from 'primereact/inputnumber'; import { InputSwitch } from 'primereact/inputswitch'; import GenericButton from '@/components/buttons/GenericButton'; -import { useToast } from '@/hooks/useToast'; +import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; -import dynamic from 'next/dynamic'; -import { Tooltip } from 'primereact/tooltip'; +import { useToast } from '@/hooks/useToast'; import 'primeicons/primeicons.css'; +import { Tooltip } from 'primereact/tooltip'; import 'primereact/resources/primereact.min.css'; - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); +import MarkdownEditor from '@/components/markdown/MarkdownEditor'; const CDN_ENDPOINT = process.env.NEXT_PUBLIC_CDN_ENDPOINT; @@ -199,9 +197,7 @@ const CombinedResourceForm = () => {
Content -
- -
+
diff --git a/src/components/forms/combined/EditDraftCombinedResourceForm.js b/src/components/forms/combined/EditDraftCombinedResourceForm.js index 832af72..4daa782 100644 --- a/src/components/forms/combined/EditDraftCombinedResourceForm.js +++ b/src/components/forms/combined/EditDraftCombinedResourceForm.js @@ -1,19 +1,17 @@ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; -import { useRouter } from 'next/router'; import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; import { InputNumber } from 'primereact/inputnumber'; import { InputSwitch } from 'primereact/inputswitch'; import GenericButton from '@/components/buttons/GenericButton'; -import { useToast } from '@/hooks/useToast'; +import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; -import dynamic from 'next/dynamic'; -import { Tooltip } from 'primereact/tooltip'; +import { useToast } from '@/hooks/useToast'; import 'primeicons/primeicons.css'; +import { Tooltip } from 'primereact/tooltip'; import 'primereact/resources/primereact.min.css'; - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); +import MarkdownEditor from '@/components/markdown/MarkdownEditor'; const CDN_ENDPOINT = process.env.NEXT_PUBLIC_CDN_ENDPOINT; @@ -242,9 +240,7 @@ const EditDraftCombinedResourceForm = ({ draft }) => {
Content -
- -
+
diff --git a/src/components/forms/combined/EditPublishedCombinedResourceForm.js b/src/components/forms/combined/EditPublishedCombinedResourceForm.js index d113993..930949a 100644 --- a/src/components/forms/combined/EditPublishedCombinedResourceForm.js +++ b/src/components/forms/combined/EditPublishedCombinedResourceForm.js @@ -1,23 +1,22 @@ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; -import { useRouter } from 'next/router'; -import { useToast } from '@/hooks/useToast'; -import { useSession } from 'next-auth/react'; -import { useNDKContext } from '@/context/NDKContext'; -import GenericButton from '@/components/buttons/GenericButton'; -import { NDKEvent } from '@nostr-dev-kit/ndk'; -import { validateEvent } from '@/utils/nostr'; import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; import { InputNumber } from 'primereact/inputnumber'; import { InputSwitch } from 'primereact/inputswitch'; +import GenericButton from '@/components/buttons/GenericButton'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { useToast } from '@/hooks/useToast'; +import { useNDKContext } from '@/context/NDKContext'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import { validateEvent } from '@/utils/nostr'; import { useEncryptContent } from '@/hooks/encryption/useEncryptContent'; import MoreInfo from '@/components/MoreInfo'; -import dynamic from 'next/dynamic'; - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { - ssr: false, -}); +import 'primeicons/primeicons.css'; +import { Tooltip } from 'primereact/tooltip'; +import 'primereact/resources/primereact.min.css'; +import MarkdownEditor from '@/components/markdown/MarkdownEditor'; const EditPublishedCombinedResourceForm = ({ event }) => { const router = useRouter(); @@ -220,9 +219,7 @@ const EditPublishedCombinedResourceForm = ({ event }) => {
Video Embed -
- -
+ You can customize your video embed using markdown or HTML. For example, paste iframe embeds from YouTube or Vimeo, or use video tags for direct video files. @@ -239,9 +236,7 @@ const EditPublishedCombinedResourceForm = ({ event }) => {
Content -
- -
+
diff --git a/src/components/forms/course/embedded/EmbeddedDocumentForm.js b/src/components/forms/course/embedded/EmbeddedDocumentForm.js index ffc6e05..53bbc26 100644 --- a/src/components/forms/course/embedded/EmbeddedDocumentForm.js +++ b/src/components/forms/course/embedded/EmbeddedDocumentForm.js @@ -1,21 +1,20 @@ import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; import { InputText } from 'primereact/inputtext'; +import { InputTextarea } from 'primereact/inputtextarea'; import { InputNumber } from 'primereact/inputnumber'; import { InputSwitch } from 'primereact/inputswitch'; +import { Calendar } from 'primereact/calendar'; import GenericButton from '@/components/buttons/GenericButton'; +import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; import { useToast } from '@/hooks/useToast'; import { useNDKContext } from '@/context/NDKContext'; import { NDKEvent } from '@nostr-dev-kit/ndk'; -import dynamic from 'next/dynamic'; -import { useEncryptContent } from '@/hooks/encryption/useEncryptContent'; - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { - ssr: false, -}); import 'primeicons/primeicons.css'; -import { Tooltip } from 'primereact/tooltip'; import 'primereact/resources/primereact.min.css'; +import { useEncryptContent } from '@/hooks/encryption/useEncryptContent'; +import MarkdownEditor from '@/components/markdown/MarkdownEditor'; const EmbeddedDocumentForm = ({ draft = null, isPublished = false, onSave, isPaid }) => { const [title, setTitle] = useState(draft?.title || ''); @@ -183,9 +182,7 @@ const EmbeddedDocumentForm = ({ draft = null, isPublished = false, onSave, isPai
Content -
- -
+
@@ -219,7 +216,6 @@ const EmbeddedDocumentForm = ({ draft = null, isPublished = false, onSave, isPai
-
{topics.map((topic, index) => ( diff --git a/src/components/forms/document/DocumentForm.js b/src/components/forms/document/DocumentForm.js index 8ffea2e..f48ea41 100644 --- a/src/components/forms/document/DocumentForm.js +++ b/src/components/forms/document/DocumentForm.js @@ -8,14 +8,10 @@ import GenericButton from '@/components/buttons/GenericButton'; import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; import { useToast } from '@/hooks/useToast'; -import dynamic from 'next/dynamic'; -import 'primeicons/primeicons.css'; import { Tooltip } from 'primereact/tooltip'; +import 'primeicons/primeicons.css'; import 'primereact/resources/primereact.min.css'; - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { - ssr: false, -}); +import MarkdownEditor from '@/components/markdown/MarkdownEditor'; const DocumentForm = () => { const [title, setTitle] = useState(''); @@ -149,9 +145,11 @@ const DocumentForm = () => {
Content -
- -
+
diff --git a/src/components/forms/document/EditDraftDocumentForm.js b/src/components/forms/document/EditDraftDocumentForm.js index 67de952..bcc1f67 100644 --- a/src/components/forms/document/EditDraftDocumentForm.js +++ b/src/components/forms/document/EditDraftDocumentForm.js @@ -8,10 +8,10 @@ import GenericButton from '@/components/buttons/GenericButton'; import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; import { useToast } from '@/hooks/useToast'; -import dynamic from 'next/dynamic'; +import 'primeicons/primeicons.css'; import { Tooltip } from 'primereact/tooltip'; - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); +import 'primereact/resources/primereact.min.css'; +import MarkdownEditor from '@/components/markdown/MarkdownEditor'; const EditDraftDocumentForm = ({ draft }) => { const [title, setTitle] = useState(draft?.title || ''); @@ -143,9 +143,7 @@ const EditDraftDocumentForm = ({ draft }) => {
Content -
- -
+
diff --git a/src/components/forms/document/EditPublishedDocumentForm.js b/src/components/forms/document/EditPublishedDocumentForm.js index 7a64b93..46fb0f9 100644 --- a/src/components/forms/document/EditPublishedDocumentForm.js +++ b/src/components/forms/document/EditPublishedDocumentForm.js @@ -1,21 +1,21 @@ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; -import { useRouter } from 'next/router'; -import { useToast } from '@/hooks/useToast'; -import { useSession } from 'next-auth/react'; -import { useNDKContext } from '@/context/NDKContext'; -import { useEncryptContent } from '@/hooks/encryption/useEncryptContent'; -import GenericButton from '@/components/buttons/GenericButton'; -import { NDKEvent } from '@nostr-dev-kit/ndk'; -import { validateEvent } from '@/utils/nostr'; import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; import { InputNumber } from 'primereact/inputnumber'; import { InputSwitch } from 'primereact/inputswitch'; +import GenericButton from '@/components/buttons/GenericButton'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { useToast } from '@/hooks/useToast'; +import { useNDKContext } from '@/context/NDKContext'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import { validateEvent } from '@/utils/nostr'; +import { useEncryptContent } from '@/hooks/encryption/useEncryptContent'; +import 'primeicons/primeicons.css'; import { Tooltip } from 'primereact/tooltip'; -import dynamic from 'next/dynamic'; - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false }); +import 'primereact/resources/primereact.min.css'; +import MarkdownEditor from '@/components/markdown/MarkdownEditor'; const EditPublishedDocumentForm = ({ event }) => { const router = useRouter(); @@ -198,9 +198,7 @@ const EditPublishedDocumentForm = ({ event }) => {
Content -
- -
+
diff --git a/src/components/forms/video/EditPublishedVideoForm.js b/src/components/forms/video/EditPublishedVideoForm.js index 6eac234..5bbc48c 100644 --- a/src/components/forms/video/EditPublishedVideoForm.js +++ b/src/components/forms/video/EditPublishedVideoForm.js @@ -1,23 +1,22 @@ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; -import { useRouter } from 'next/router'; -import { useToast } from '@/hooks/useToast'; -import { useSession } from 'next-auth/react'; -import { useNDKContext } from '@/context/NDKContext'; -import GenericButton from '@/components/buttons/GenericButton'; -import { NDKEvent } from '@nostr-dev-kit/ndk'; -import { validateEvent } from '@/utils/nostr'; import { InputText } from 'primereact/inputtext'; import { InputTextarea } from 'primereact/inputtextarea'; import { InputNumber } from 'primereact/inputnumber'; import { InputSwitch } from 'primereact/inputswitch'; +import GenericButton from '@/components/buttons/GenericButton'; +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { useToast } from '@/hooks/useToast'; +import { useNDKContext } from '@/context/NDKContext'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import { validateEvent } from '@/utils/nostr'; import { useEncryptContent } from '@/hooks/encryption/useEncryptContent'; import MoreInfo from '@/components/MoreInfo'; -import dynamic from 'next/dynamic'; - -const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { - ssr: false, -}); +import 'primeicons/primeicons.css'; +import { Tooltip } from 'primereact/tooltip'; +import 'primereact/resources/primereact.min.css'; +import MarkdownEditor from '@/components/markdown/MarkdownEditor'; const EditPublishedVideoForm = ({ event }) => { const router = useRouter(); @@ -190,9 +189,7 @@ const EditPublishedVideoForm = ({ event }) => {
Video Embed -
- -
+ You can customize your video embed using markdown or HTML. For example, paste iframe embeds from YouTube or Vimeo, or use video tags for direct video files. diff --git a/src/components/markdown/MarkdownEditor.js b/src/components/markdown/MarkdownEditor.js new file mode 100644 index 0000000..5950860 --- /dev/null +++ b/src/components/markdown/MarkdownEditor.js @@ -0,0 +1,128 @@ +import React from 'react'; +import dynamic from 'next/dynamic'; +import '@uiw/react-md-editor/markdown-editor.css'; +import '@uiw/react-markdown-preview/markdown.css'; +import 'github-markdown-css/github-markdown-dark.css'; + +// Custom theme for MDEditor +const mdEditorDarkTheme = { + markdown: '#fff', + markdownH1: '#fff', + markdownH2: '#fff', + markdownH3: '#fff', + markdownH4: '#fff', + markdownH5: '#fff', + markdownH6: '#fff', + markdownParagraph: '#fff', + markdownLink: '#58a6ff', + markdownCode: '#fff', + markdownList: '#fff', + markdownBlockquote: '#fff', + markdownTable: '#fff', +}; + +// Dynamically import MDEditor with custom theming +const MDEditor = dynamic(() => import('@uiw/react-md-editor').then(mod => { + // Override the module's default theme + if (mod.default) { + mod.default.Markdown = { + ...mod.default.Markdown, + ...mdEditorDarkTheme + }; + } + return mod; +}), { + ssr: false, +}); + +/** + * A reusable markdown editor component with proper dark mode styling + * + * @param {Object} props + * @param {string} props.value - The markdown content + * @param {Function} props.onChange - Callback function when content changes + * @param {number} props.height - Height of the editor (default: 300) + * @param {string} props.placeholder - Placeholder text for the editor + * @param {string} props.preview - Preview mode ('edit', 'preview', 'live') (default: 'edit') + * @param {string} props.className - Additional class names + * @returns {JSX.Element} + */ +const MarkdownEditor = ({ + value, + onChange, + height = 300, + placeholder = "Write your content here...", + preview = "edit", + className = "", + ...props +}) => { + return ( +
+ + +
+ ); +}; + +export default MarkdownEditor; \ No newline at end of file From e3317f870aca299ea9455ecd35db596aa175c245 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 11 May 2025 16:14:15 -0500 Subject: [PATCH 06/17] smoother course navigation --- .../content/courses/layout/CourseSidebar.js | 4 +- .../content/courses/tabs/CourseContent.js | 54 ++++++++++++++++--- src/hooks/courses/useCourseNavigation.js | 5 +- src/hooks/encryption/useCourseDecryption.js | 19 +++---- src/pages/course/[slug]/index.js | 44 +++++++++++---- 5 files changed, 95 insertions(+), 31 deletions(-) diff --git a/src/components/content/courses/layout/CourseSidebar.js b/src/components/content/courses/layout/CourseSidebar.js index 189e7f1..778a377 100644 --- a/src/components/content/courses/layout/CourseSidebar.js +++ b/src/components/content/courses/layout/CourseSidebar.js @@ -37,8 +37,8 @@ const CourseSidebar = ({ ${isMobileView ? 'mb-3' : 'mb-2'} `} onClick={() => { - // Force full page refresh to trigger proper decryption - window.location.href = `/course/${window.location.pathname.split('/').pop()}?active=${index}`; + // Use smooth navigation function instead of forcing page refresh + onLessonSelect(index); }} >
diff --git a/src/components/content/courses/tabs/CourseContent.js b/src/components/content/courses/tabs/CourseContent.js index e4e7953..79b37ac 100644 --- a/src/components/content/courses/tabs/CourseContent.js +++ b/src/components/content/courses/tabs/CourseContent.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import VideoLesson from '@/components/content/courses/lessons/VideoLesson'; import DocumentLesson from '@/components/content/courses/lessons/DocumentLesson'; import CombinedLesson from '@/components/content/courses/lessons/CombinedLesson'; @@ -15,6 +15,40 @@ const CourseContent = ({ decryptedLessonIds, setCompleted }) => { + const [lastActiveIndex, setLastActiveIndex] = useState(activeIndex); + const [isTransitioning, setIsTransitioning] = useState(false); + const [currentLesson, setCurrentLesson] = useState(null); + + // Initialize current lesson and handle updates when lessons or activeIndex change + useEffect(() => { + if (lessons.length > 0 && activeIndex < lessons.length) { + setCurrentLesson(lessons[activeIndex]); + } else { + setCurrentLesson(null); + } + }, [lessons, activeIndex]); + + // Handle smooth transitions between lessons + useEffect(() => { + if (activeIndex !== lastActiveIndex) { + // Start transition + setIsTransitioning(true); + + // After a short delay, update the current lesson + const timer = setTimeout(() => { + setCurrentLesson(lessons[activeIndex] || null); + setLastActiveIndex(activeIndex); + + // End transition with a slight delay to ensure content is ready + setTimeout(() => { + setIsTransitioning(false); + }, 50); + }, 300); // Match this with CSS transition duration + + return () => clearTimeout(timer); + } + }, [activeIndex, lastActiveIndex, lessons]); + const renderLesson = (lesson) => { if (!lesson) return null; @@ -24,6 +58,7 @@ const CourseContent = ({ if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) { return ( ); - } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { + } else if (lesson.type === 'video' || lesson.topics?.includes('video')) { return ( ); - } else if (lesson.type === 'document' && !lesson.topics?.includes('video')) { + } else if (lesson.type === 'document' || lesson.topics?.includes('document')) { return ( - {lessons.length > 0 && lessons[activeIndex] ? ( + {lessons.length > 0 && currentLesson ? (
-
- {renderLesson(lessons[activeIndex])} +
+ {renderLesson(currentLesson)}
) : ( -
+

Select a lesson from the sidebar to begin learning.

)} diff --git a/src/hooks/courses/useCourseNavigation.js b/src/hooks/courses/useCourseNavigation.js index 8f502b4..7484c74 100644 --- a/src/hooks/courses/useCourseNavigation.js +++ b/src/hooks/courses/useCourseNavigation.js @@ -44,7 +44,10 @@ const useCourseNavigation = (router, isMobileView) => { // Function to handle lesson selection const handleLessonSelect = (index) => { setActiveIndex(index); - router.push(`/course/${router.query.slug}?active=${index}`, undefined, { shallow: true }); + + // Update URL without causing a page reload (for bookmarking purposes) + const newUrl = `/course/${router.query.slug}?active=${index}`; + window.history.replaceState({ url: newUrl, as: newUrl, options: { shallow: true } }, '', newUrl); // On mobile, switch to content tab after selection if (isMobileView) { diff --git a/src/hooks/encryption/useCourseDecryption.js b/src/hooks/encryption/useCourseDecryption.js index 6e9046f..e7a06f6 100644 --- a/src/hooks/encryption/useCourseDecryption.js +++ b/src/hooks/encryption/useCourseDecryption.js @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useDecryptContent } from './useDecryptContent'; -const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, router) => { +const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, router, activeIndex = 0) => { const [decryptedLessonIds, setDecryptedLessonIds] = useState({}); const [loading, setLoading] = useState(false); const { decryptContent } = useDecryptContent(); @@ -10,8 +10,8 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r const retryCountRef = useRef({}); const MAX_RETRIES = 3; - // Get the current active lesson - const currentLessonIndex = router.query.active ? parseInt(router.query.active, 10) : 0; + // Get the current active lesson using the activeIndex prop instead of router.query + const currentLessonIndex = activeIndex; const currentLesson = lessons.length > 0 ? lessons[currentLessonIndex] : null; const currentLessonId = currentLesson?.id; @@ -35,8 +35,9 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r useEffect(() => { if (currentLessonId && lastLessonIdRef.current !== currentLessonId) { retryCountRef.current[currentLessonId] = 0; + lastLessonIdRef.current = currentLessonId; } - }, [currentLessonId]); + }, [currentLessonId, activeIndex]); // Simplified decrypt function const decryptCurrentLesson = useCallback(async () => { @@ -120,6 +121,7 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r retryCountRef.current[currentLesson.id] = 0; } catch (error) { // Silent error handling to prevent UI disruption + console.error('Decryption error:', error); } finally { setLoading(false); processingRef.current = false; @@ -130,16 +132,11 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r useEffect(() => { if (!currentLessonId) return; - // Skip if the lesson hasn't changed, unless it failed decryption previously - if (lastLessonIdRef.current === currentLessonId && decryptedLessonIds[currentLessonId]) return; - - // Update the last processed lesson id - lastLessonIdRef.current = currentLessonId; - + // Always attempt decryption when activeIndex changes if (hasAccess && paidCourse && !decryptedLessonIds[currentLessonId]) { decryptCurrentLesson(); } - }, [currentLessonId, hasAccess, paidCourse, decryptedLessonIds, decryptCurrentLesson]); + }, [currentLessonId, hasAccess, paidCourse, decryptedLessonIds, decryptCurrentLesson, activeIndex]); return { decryptionPerformed: isCurrentLessonDecrypted, diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index eb7fe99..fa7ac71 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -34,6 +34,7 @@ const Course = () => { const [nsec, setNsec] = useState(null); const [npub, setNpub] = useState(null); const [nAddress, setNAddress] = useState(null); + const [isDecrypting, setIsDecrypting] = useState(false); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 968; const navbarHeight = 60; // Match the height from Navbar component @@ -100,9 +101,23 @@ const Course = () => { course, lessons, setLessons, - router + router, + activeIndex ); + useEffect(() => { + if (paidCourse && uniqueLessons.length > 0) { + const currentLesson = uniqueLessons[activeIndex]; + if (currentLesson && !decryptedLessonIds[currentLesson.id]) { + setIsDecrypting(true); + } else { + setIsDecrypting(false); + } + } else { + setIsDecrypting(false); + } + }, [activeIndex, uniqueLessons, decryptedLessonIds, paidCourse]); + useEffect(() => { if (uniqueLessons.length > 0) { const addresses = {}; @@ -156,7 +171,7 @@ const Course = () => { ); }; - if (courseLoading || decryptionLoading) { + if (courseLoading) { return (
@@ -204,14 +219,23 @@ const Course = () => { {/* Content tab content */}
- + {isDecrypting || decryptionLoading ? ( +
+
+ +

Decrypting lesson content...

+
+
+ ) : ( + + )}
{/* QA tab content */} From 9bd0c0877d326475670700d97a944f656621f830 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 12 May 2025 08:20:02 -0500 Subject: [PATCH 07/17] fix completed lesson state for all lessons in sidebar --- src/pages/course/[slug]/index.js | 38 ++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index fa7ac71..ee6c9cf 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -67,9 +67,43 @@ const Course = () => { } }, [router.isReady, router.query.slug, showToast, router]); + // Load completed lessons from localStorage when course is loaded + useEffect(() => { + if (router.isReady && router.query.slug && session?.user) { + const courseId = router.query.slug; + const storageKey = `course_${courseId}_${session.user.pubkey}_completed`; + const savedCompletedLessons = localStorage.getItem(storageKey); + + if (savedCompletedLessons) { + try { + const parsedLessons = JSON.parse(savedCompletedLessons); + setCompletedLessons(parsedLessons); + } catch (error) { + console.error('Error parsing completed lessons from storage:', error); + } + } + } + }, [router.isReady, router.query.slug, session]); + const setCompleted = useCallback(lessonId => { - setCompletedLessons(prev => [...prev, lessonId]); - }, []); + setCompletedLessons(prev => { + // Avoid duplicates + if (prev.includes(lessonId)) { + return prev; + } + + const newCompletedLessons = [...prev, lessonId]; + + // Save to localStorage + if (router.query.slug && session?.user) { + const courseId = router.query.slug; + const storageKey = `course_${courseId}_${session.user.pubkey}_completed`; + localStorage.setItem(storageKey, JSON.stringify(newCompletedLessons)); + } + + return newCompletedLessons; + }); + }, [router.query.slug, session]); const fetchAuthor = useCallback( async pubkey => { From cfefabfcdfdbbd939ea0fa39f989086b65fce55c Mon Sep 17 00:00:00 2001 From: Austin Kelsay <53542748+AustinKelsay@users.noreply.github.com> Date: Mon, 12 May 2025 09:29:35 -0500 Subject: [PATCH 08/17] Update src/hooks/courses/useCourseTabs.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/hooks/courses/useCourseTabs.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hooks/courses/useCourseTabs.js b/src/hooks/courses/useCourseTabs.js index 0714aad..dffcda8 100644 --- a/src/hooks/courses/useCourseTabs.js +++ b/src/hooks/courses/useCourseTabs.js @@ -12,8 +12,7 @@ import useWindowWidth from '../useWindowWidth'; const useCourseTabs = (options = {}) => { const router = useRouter(); const windowWidth = useWindowWidth(); - const isMobileView = windowWidth <= 968; - + const isMobileView = typeof windowWidth === 'number' ? windowWidth <= 968 : false; // Tab management state const [activeTab, setActiveTab] = useState('overview'); const [sidebarVisible, setSidebarVisible] = useState( From cfac21a6665be2e72dda22f20d8a4068294eeff2 Mon Sep 17 00:00:00 2001 From: Austin Kelsay <53542748+AustinKelsay@users.noreply.github.com> Date: Mon, 12 May 2025 09:30:21 -0500 Subject: [PATCH 09/17] Update src/components/content/courses/tabs/CourseOverview.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/components/content/courses/tabs/CourseOverview.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/content/courses/tabs/CourseOverview.js b/src/components/content/courses/tabs/CourseOverview.js index 67dd049..20d0408 100644 --- a/src/components/content/courses/tabs/CourseOverview.js +++ b/src/components/content/courses/tabs/CourseOverview.js @@ -16,7 +16,7 @@ const CourseOverview = ({ completedLessons }) => { // Determine if course is completed - const isCompleted = completedLessons.length > 0; + const isCompleted = lessons && lessons.length > 0 && completedLessons.length === lessons.length; return (
From 44929d54b95a0b9a9d4e38259c447feef2348349 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 12 May 2025 09:31:24 -0500 Subject: [PATCH 10/17] buffer import to account for polyfill, remove unused imports --- package-lock.json | 65 +++++++++++++++++++ package.json | 1 + .../course/embedded/EmbeddedDocumentForm.js | 4 -- src/pages/course/[slug]/index.js | 1 + 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4290a62..8795c16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@vercel/kv": "^3.0.0", "axios": "^1.7.2", "bech32": "^2.0.0", + "buffer": "^6.0.3", "chart.js": "^4.4.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -5555,6 +5556,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcp-47-match": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", @@ -5662,6 +5683,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8178,6 +8223,26 @@ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "license": "ISC" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index de05b28..c93b9b3 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@vercel/kv": "^3.0.0", "axios": "^1.7.2", "bech32": "^2.0.0", + "buffer": "^6.0.3", "chart.js": "^4.4.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/src/components/forms/course/embedded/EmbeddedDocumentForm.js b/src/components/forms/course/embedded/EmbeddedDocumentForm.js index 53bbc26..2a7cc14 100644 --- a/src/components/forms/course/embedded/EmbeddedDocumentForm.js +++ b/src/components/forms/course/embedded/EmbeddedDocumentForm.js @@ -1,12 +1,8 @@ import React, { useState, useEffect, useCallback } from 'react'; -import axios from 'axios'; import { InputText } from 'primereact/inputtext'; -import { InputTextarea } from 'primereact/inputtextarea'; import { InputNumber } from 'primereact/inputnumber'; import { InputSwitch } from 'primereact/inputswitch'; -import { Calendar } from 'primereact/calendar'; import GenericButton from '@/components/buttons/GenericButton'; -import { useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; import { useToast } from '@/hooks/useToast'; import { useNDKContext } from '@/context/NDKContext'; diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index ee6c9cf..f6985b3 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -6,6 +6,7 @@ import { useSession } from 'next-auth/react'; import { nip19 } from 'nostr-tools'; import { useToast } from '@/hooks/useToast'; import { ProgressSpinner } from 'primereact/progressspinner'; +import { Buffer } from 'buffer'; // Hooks import useCourseDecryption from '@/hooks/encryption/useCourseDecryption'; From f8bd1ddb43f05bc9adac4b8aea64a185cf88162b Mon Sep 17 00:00:00 2001 From: Austin Kelsay <53542748+AustinKelsay@users.noreply.github.com> Date: Mon, 12 May 2025 09:36:43 -0500 Subject: [PATCH 11/17] Update src/hooks/courses/useCoursePayment.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/hooks/courses/useCoursePayment.js | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/hooks/courses/useCoursePayment.js b/src/hooks/courses/useCoursePayment.js index 4743cf1..c2740b8 100644 --- a/src/hooks/courses/useCoursePayment.js +++ b/src/hooks/courses/useCoursePayment.js @@ -34,11 +34,23 @@ const useCoursePayment = (course) => { // Handler for successful payment const handlePaymentSuccess = useCallback(async (response) => { - if (response && response?.preimage) { - // Update session to reflect purchase - const updated = await update(); - showToast('success', 'Payment Success', 'You have successfully purchased this course'); - return true; + if (response?.preimage) { + try { + await update(); // refresh session + showToast( + 'success', + 'Payment Success', + 'You have successfully purchased this course' + ); + return true; + } catch (err) { + showToast( + 'warn', + 'Session Refresh Failed', + 'Purchase succeeded but we could not refresh your session automatically. Please reload the page.' + ); + return false; + } } else { showToast('error', 'Error', 'Failed to purchase course. Please try again.'); return false; From bac65c0dd3f551c8bf658b4ff3683c4ec811af39 Mon Sep 17 00:00:00 2001 From: Austin Kelsay <53542748+AustinKelsay@users.noreply.github.com> Date: Mon, 12 May 2025 09:44:29 -0500 Subject: [PATCH 12/17] Update src/hooks/courses/useCourseData.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/hooks/courses/useCourseData.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/hooks/courses/useCourseData.js b/src/hooks/courses/useCourseData.js index c6a6663..def9f34 100644 --- a/src/hooks/courses/useCourseData.js +++ b/src/hooks/courses/useCourseData.js @@ -24,8 +24,22 @@ const useCourseData = (ndk, fetchAuthor, router) => { let id; const fetchCourseId = async () => { - if (slug.includes('naddr')) { - const { data } = nip19.decode(slug); + // Normalise slug to a string and exit early if it’s still falsy + const slugStr = Array.isArray(slug) ? slug[0] : slug; + if (!slugStr) { + showToast('error', 'Error', 'Invalid course identifier'); + return null; + } + + if (slugStr.includes('naddr')) { + let data; + try { + ({ data } = nip19.decode(slugStr)); + } catch (err) { + showToast('error', 'Error', 'Malformed naddr'); + return null; + } + if (!data?.identifier) { showToast('error', 'Error', 'Resource not found'); return null; From 5a79523ba0638a6feaf567635f675b756ecea253 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 12 May 2025 09:37:32 -0500 Subject: [PATCH 13/17] clean up decryption and retry timers on unmount to prevent memory leaks --- src/hooks/encryption/useCourseDecryption.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/hooks/encryption/useCourseDecryption.js b/src/hooks/encryption/useCourseDecryption.js index e7a06f6..2f57781 100644 --- a/src/hooks/encryption/useCourseDecryption.js +++ b/src/hooks/encryption/useCourseDecryption.js @@ -8,6 +8,8 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r const processingRef = useRef(false); const lastLessonIdRef = useRef(null); const retryCountRef = useRef({}); + const retryTimeoutRef = useRef(null); + const decryptTimeoutRef = useRef(null); const MAX_RETRIES = 3; // Get the current active lesson using the activeIndex prop instead of router.query @@ -70,12 +72,12 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r let timeoutId; const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => { - // Cancel the in-flight request when timeout occurs if (decryptionPromise.cancel) { decryptionPromise.cancel(); } reject(new Error('Decryption timeout')); }, 10000); + decryptTimeoutRef.current = timeoutId; }); // Use a separate try-catch for the race @@ -89,9 +91,10 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r // Clear the timeout if decryption wins clearTimeout(timeoutId); + decryptTimeoutRef.current = null; } catch (error) { // If timeout or network error, schedule a retry - setTimeout(() => { + retryTimeoutRef.current = setTimeout(() => { processingRef.current = false; decryptCurrentLesson(); }, 5000); @@ -138,6 +141,19 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r } }, [currentLessonId, hasAccess, paidCourse, decryptedLessonIds, decryptCurrentLesson, activeIndex]); + useEffect(() => { + return () => { + if (decryptTimeoutRef.current) { + clearTimeout(decryptTimeoutRef.current); + decryptTimeoutRef.current = null; + } + if (retryTimeoutRef.current) { + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + } + }; + }, []); + return { decryptionPerformed: isCurrentLessonDecrypted, loading, From ccda05df966e3a45eb0884b69fcb1f1d4b299039 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 12 May 2025 09:50:33 -0500 Subject: [PATCH 14/17] deduplicate course tab logic, add pure useCourseTabsState, and sync tab state with URL --- src/hooks/courses/index.js | 6 +- src/hooks/courses/useCourseNavigation.js | 103 ++++------------- src/hooks/courses/useCourseTabs.js | 122 ++++++-------------- src/hooks/courses/useCourseTabsState.js | 140 +++++++++++++++++++++++ 4 files changed, 205 insertions(+), 166 deletions(-) create mode 100644 src/hooks/courses/useCourseTabsState.js diff --git a/src/hooks/courses/index.js b/src/hooks/courses/index.js index f6438e0..82bc2e2 100644 --- a/src/hooks/courses/index.js +++ b/src/hooks/courses/index.js @@ -3,11 +3,15 @@ import useCourseTabs from './useCourseTabs'; import useCoursePayment from './useCoursePayment'; import useCourseData from './useCourseData'; import useLessons from './useLessons'; +import useCourseNavigation from './useCourseNavigation'; +import useCourseTabsState from './useCourseTabsState'; export { useCourseDecryption, useCourseTabs, useCoursePayment, useCourseData, - useLessons + useLessons, + useCourseNavigation, + useCourseTabsState }; \ No newline at end of file diff --git a/src/hooks/courses/useCourseNavigation.js b/src/hooks/courses/useCourseNavigation.js index 7484c74..571718c 100644 --- a/src/hooks/courses/useCourseNavigation.js +++ b/src/hooks/courses/useCourseNavigation.js @@ -1,4 +1,5 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import useCourseTabsState from './useCourseTabsState'; /** * Hook to manage course navigation and tab logic @@ -8,19 +9,20 @@ import { useState, useEffect, useMemo } from 'react'; */ const useCourseNavigation = (router, isMobileView) => { const [activeIndex, setActiveIndex] = useState(0); - const [sidebarVisible, setSidebarVisible] = useState(false); - const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab - - // Memoized function to get the tab map based on view mode - const tabMap = useMemo(() => { - const baseTabMap = ['overview', 'content', 'qa']; - if (isMobileView) { - const mobileTabMap = [...baseTabMap]; - mobileTabMap.splice(2, 0, 'lessons'); - return mobileTabMap; - } - return baseTabMap; - }, [isMobileView]); + + // Use the base hook for core tab state functionality + const { + activeTab, + setActiveTab, + sidebarVisible, + setSidebarVisible, + tabMap, + getActiveTabIndex, + getTabItems, + toggleSidebar: baseToggleSidebar + } = useCourseTabsState({ + isMobileView + }); // Initialize navigation state based on router useEffect(() => { @@ -39,10 +41,10 @@ const useCourseNavigation = (router, isMobileView) => { // Auto-open sidebar on desktop, close on mobile setSidebarVisible(!isMobileView); } - }, [router.isReady, router.query, isMobileView]); + }, [router.isReady, router.query, isMobileView, setActiveTab, setSidebarVisible]); // Function to handle lesson selection - const handleLessonSelect = (index) => { + const handleLessonSelect = useCallback((index) => { setActiveIndex(index); // Update URL without causing a page reload (for bookmarking purposes) @@ -54,10 +56,10 @@ const useCourseNavigation = (router, isMobileView) => { setActiveTab('content'); setSidebarVisible(false); } - }; + }, [router.query.slug, isMobileView, setActiveTab, setSidebarVisible]); - // Function to toggle tab - const toggleTab = (index) => { + // Function to toggle tab with lesson state integration + const toggleTab = useCallback((index) => { const tabName = tabMap[index]; setActiveTab(tabName); @@ -65,66 +67,7 @@ const useCourseNavigation = (router, isMobileView) => { if (isMobileView) { setSidebarVisible(tabName === 'lessons'); } - }; - - // Function to toggle sidebar visibility - const toggleSidebar = () => { - setSidebarVisible(!sidebarVisible); - }; - - // Map active tab name back to index for MenuTab - const getActiveTabIndex = () => { - return tabMap.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) % tabMap.length; - toggleTab(nextIndex); - } else if (e.key === 'ArrowLeft') { - const currentIndex = getActiveTabIndex(); - const prevIndex = (currentIndex - 1 + tabMap.length) % tabMap.length; - toggleTab(prevIndex); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [activeTab, tabMap]); + }, [tabMap, isMobileView, setActiveTab, setSidebarVisible]); return { activeIndex, @@ -135,7 +78,7 @@ const useCourseNavigation = (router, isMobileView) => { setSidebarVisible, handleLessonSelect, toggleTab, - toggleSidebar, + toggleSidebar: baseToggleSidebar, getActiveTabIndex, getTabItems, tabMap diff --git a/src/hooks/courses/useCourseTabs.js b/src/hooks/courses/useCourseTabs.js index dffcda8..f0ab071 100644 --- a/src/hooks/courses/useCourseTabs.js +++ b/src/hooks/courses/useCourseTabs.js @@ -1,8 +1,10 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useEffect, useCallback } from 'react'; import { useRouter } from 'next/router'; import useWindowWidth from '../useWindowWidth'; +import useCourseTabsState from './useCourseTabsState'; /** + * @deprecated Use useCourseTabsState for pure state or useCourseNavigation for router integration * Hook to manage course tabs, navigation, and sidebar visibility * @param {Object} options - Configuration options * @param {Array} options.tabMap - Optional custom tab map to use @@ -13,49 +15,42 @@ const useCourseTabs = (options = {}) => { const router = useRouter(); const windowWidth = useWindowWidth(); const isMobileView = typeof windowWidth === 'number' ? windowWidth <= 968 : false; - // Tab management state - const [activeTab, setActiveTab] = useState('overview'); - const [sidebarVisible, setSidebarVisible] = useState( - options.initialSidebarVisible !== undefined ? options.initialSidebarVisible : !isMobileView - ); - // Get tab map based on view mode - const tabMap = useMemo(() => { - const baseTabMap = options.tabMap || ['overview', 'content', 'qa']; - if (isMobileView) { - const mobileTabMap = [...baseTabMap]; - // Insert lessons tab before qa in mobile view - if (!mobileTabMap.includes('lessons')) { - mobileTabMap.splice(2, 0, 'lessons'); - } - return mobileTabMap; - } - return baseTabMap; - }, [isMobileView, options.tabMap]); + // Use the base hook for core tab state functionality + const { + activeTab, + setActiveTab, + sidebarVisible, + setSidebarVisible, + tabMap, + getActiveTabIndex, + getTabItems, + toggleSidebar + } = useCourseTabsState({ + tabMap: options.tabMap, + initialSidebarVisible: options.initialSidebarVisible, + isMobileView + }); // Update tabs and sidebar based on router query useEffect(() => { if (router.isReady) { - const { active } = router.query; - if (active !== undefined) { + const { active, tab } = router.query; + + // If tab is specified in the URL, use that + if (tab && tabMap.includes(tab)) { + setActiveTab(tab); + } else if (active !== undefined) { // If we have an active lesson, switch to content tab setActiveTab('content'); } else { - // Default to overview tab when no active parameter + // Default to overview tab when no parameters setActiveTab('overview'); } - - // Auto-open sidebar on desktop, close on mobile - setSidebarVisible(!isMobileView); } - }, [router.isReady, router.query, isMobileView]); + }, [router.isReady, router.query, tabMap, setActiveTab]); - // Get active tab index - const getActiveTabIndex = useCallback(() => { - return tabMap.indexOf(activeTab); - }, [activeTab, tabMap]); - - // Toggle between tabs + // Toggle between tabs with router integration const toggleTab = useCallback((indexOrName) => { const tabName = typeof indexOrName === 'number' ? tabMap[indexOrName] @@ -67,61 +62,18 @@ const useCourseTabs = (options = {}) => { if (isMobileView) { setSidebarVisible(tabName === 'lessons'); } - }, [tabMap, isMobileView]); - - // Toggle sidebar visibility - const toggleSidebar = useCallback(() => { - setSidebarVisible(prev => !prev); - }, []); - - // Generate tab items for MenuTab component - const getTabItems = useCallback(() => { - 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; - }, [isMobileView]); - - // Setup keyboard navigation for tabs - useEffect(() => { - const handleKeyDown = (e) => { - if (e.key === 'ArrowRight') { - const currentIndex = getActiveTabIndex(); - const nextIndex = (currentIndex + 1) % tabMap.length; - toggleTab(nextIndex); - } else if (e.key === 'ArrowLeft') { - const currentIndex = getActiveTabIndex(); - const prevIndex = (currentIndex - 1 + tabMap.length) % tabMap.length; - toggleTab(prevIndex); - } + // Sync URL with tab change using shallow routing + const newQuery = { + ...router.query, + tab: tabName === 'overview' ? undefined : tabName }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [getActiveTabIndex, tabMap, toggleTab]); + router.push( + { pathname: router.pathname, query: newQuery }, + undefined, + { shallow: true } + ); + }, [tabMap, isMobileView, router, setActiveTab, setSidebarVisible]); return { activeTab, diff --git a/src/hooks/courses/useCourseTabsState.js b/src/hooks/courses/useCourseTabsState.js new file mode 100644 index 0000000..bb3bc06 --- /dev/null +++ b/src/hooks/courses/useCourseTabsState.js @@ -0,0 +1,140 @@ +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; + +/** + * Base hook for tab state management with no router or side-effects + * This pure hook manages the tab state and sidebar visibility + * + * @param {Object} options - Configuration options + * @param {Array} options.tabMap - Optional custom tab map to use + * @param {boolean} options.initialSidebarVisible - Initial sidebar visibility state + * @param {boolean} options.isMobileView - Whether the current view is mobile + * @returns {Object} Pure tab management utilities and state + */ +const useCourseTabsState = (options = {}) => { + const { + tabMap: customTabMap, + initialSidebarVisible, + isMobileView = false + } = options; + + // Tab management state + const [activeTab, setActiveTab] = useState('overview'); + const [sidebarVisible, setSidebarVisible] = useState( + initialSidebarVisible !== undefined ? initialSidebarVisible : !isMobileView + ); + + // Track if we've initialized yet + const initialized = useRef(false); + + // Get tab map based on view mode + const tabMap = useMemo(() => { + const baseTabMap = customTabMap || ['overview', 'content', 'qa']; + if (isMobileView) { + const mobileTabMap = [...baseTabMap]; + // Insert lessons tab before qa in mobile view + if (!mobileTabMap.includes('lessons')) { + mobileTabMap.splice(2, 0, 'lessons'); + } + return mobileTabMap; + } + return baseTabMap; + }, [isMobileView, customTabMap]); + + // Auto-update sidebar visibility based on mobile/desktop + useEffect(() => { + if (initialized.current) { + // Only auto-update sidebar visibility if we're initialized + // and the view mode changes + setSidebarVisible(!isMobileView); + } else { + initialized.current = true; + } + }, [isMobileView]); + + // Get active tab index + const getActiveTabIndex = useCallback(() => { + return tabMap.indexOf(activeTab); + }, [activeTab, tabMap]); + + // Pure toggle between tabs with no side effects + const toggleTab = useCallback((indexOrName) => { + const tabName = typeof indexOrName === 'number' + ? tabMap[indexOrName] + : indexOrName; + + setActiveTab(tabName); + + // Only show/hide sidebar on mobile - desktop keeps sidebar visible + if (isMobileView) { + setSidebarVisible(tabName === 'lessons'); + } + }, [tabMap, isMobileView]); + + // Toggle sidebar visibility + const toggleSidebar = useCallback(() => { + setSidebarVisible(prev => !prev); + }, []); + + // Generate tab items for MenuTab component + const getTabItems = useCallback(() => { + 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; + }, [isMobileView]); + + // Setup keyboard navigation for tabs + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'ArrowRight') { + const currentIndex = getActiveTabIndex(); + const nextIndex = (currentIndex + 1) % tabMap.length; + toggleTab(nextIndex); + } else if (e.key === 'ArrowLeft') { + const currentIndex = getActiveTabIndex(); + const prevIndex = (currentIndex - 1 + tabMap.length) % tabMap.length; + toggleTab(prevIndex); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [getActiveTabIndex, tabMap, toggleTab]); + + return { + activeTab, + setActiveTab, + sidebarVisible, + setSidebarVisible, + toggleTab, + toggleSidebar, + getActiveTabIndex, + getTabItems, + tabMap + }; +}; + +export default useCourseTabsState; \ No newline at end of file From 403948f7b2e89ed880d88224876d32a60b149496 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 12 May 2025 11:17:57 -0500 Subject: [PATCH 15/17] fix for memory leak --- src/hooks/encryption/useCourseDecryption.js | 1 + src/pages/course/[slug]/index.js | 21 +++++++-------------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/hooks/encryption/useCourseDecryption.js b/src/hooks/encryption/useCourseDecryption.js index 2f57781..b1b4b53 100644 --- a/src/hooks/encryption/useCourseDecryption.js +++ b/src/hooks/encryption/useCourseDecryption.js @@ -93,6 +93,7 @@ const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, r clearTimeout(timeoutId); decryptTimeoutRef.current = null; } catch (error) { + clearTimeout(timeoutId); // If timeout or network error, schedule a retry retryTimeoutRef.current = setTimeout(() => { processingRef.current = false; diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index f6985b3..cd81817 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useRouter } from 'next/router'; import { findKind0Fields } from '@/utils/nostr'; import { useNDKContext } from '@/context/NDKContext'; @@ -35,7 +35,6 @@ const Course = () => { const [nsec, setNsec] = useState(null); const [npub, setNpub] = useState(null); const [nAddress, setNAddress] = useState(null); - const [isDecrypting, setIsDecrypting] = useState(false); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 968; const navbarHeight = 60; // Match the height from Navbar component @@ -140,18 +139,12 @@ const Course = () => { activeIndex ); - useEffect(() => { - if (paidCourse && uniqueLessons.length > 0) { - const currentLesson = uniqueLessons[activeIndex]; - if (currentLesson && !decryptedLessonIds[currentLesson.id]) { - setIsDecrypting(true); - } else { - setIsDecrypting(false); - } - } else { - setIsDecrypting(false); - } - }, [activeIndex, uniqueLessons, decryptedLessonIds, paidCourse]); + // Replace useState + useEffect with useMemo for derived state + const isDecrypting = useMemo(() => { + if (!paidCourse || uniqueLessons.length === 0) return false; + const current = uniqueLessons[activeIndex]; + return current && !decryptedLessonIds[current.id]; + }, [paidCourse, uniqueLessons, activeIndex, decryptedLessonIds]); useEffect(() => { if (uniqueLessons.length > 0) { From 8b6a7c39e7bea228ade9d9e8d4d7c60289a0efab Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 12 May 2025 11:58:29 -0500 Subject: [PATCH 16/17] remove test account from config --- src/config/appConfig.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/config/appConfig.js b/src/config/appConfig.js index 4363377..a8325ba 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,8 +11,7 @@ const appConfig = { ], authorPubkeys: [ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', - 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345', - '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4' + 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' ], customLightningAddresses: [ { From c995048dddc1350aa1a4800e5a3dacd5521c1c46 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 12 May 2025 12:09:01 -0500 Subject: [PATCH 17/17] fix bottom bar margin when present --- src/pages/_app.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/_app.js b/src/pages/_app.js index a451a7d..1def3f4 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -31,7 +31,9 @@ export default function MyApp({ Component, pageProps: { session, ...pageProps } - +
+ +