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/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 96% rename from src/components/content/courses/CourseSidebar.js rename to src/components/content/courses/layout/CourseSidebar.js index 189e7f1..778a377 100644 --- a/src/components/content/courses/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/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..79b37ac --- /dev/null +++ b/src/components/content/courses/tabs/CourseContent.js @@ -0,0 +1,122 @@ +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'; +import MarkdownDisplay from '@/components/markdown/MarkdownDisplay'; + +/** + * Component to display course content including lessons + */ +const CourseContent = ({ + lessons, + activeIndex, + course, + paidCourse, + 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; + + // 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('video')) { + return ( + + ); + } else if (lesson.type === 'document' || lesson.topics?.includes('document')) { + return ( + + ); + } + + return null; + }; + + return ( + <> + {lessons.length > 0 && currentLesson ? ( +
+
+ {renderLesson(currentLesson)} +
+
+ ) : ( +
+

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..20d0408 --- /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 = lessons && lessons.length > 0 && completedLessons.length === lessons.length; + + 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/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..2a7cc14 100644 --- a/src/components/forms/course/embedded/EmbeddedDocumentForm.js +++ b/src/components/forms/course/embedded/EmbeddedDocumentForm.js @@ -7,15 +7,10 @@ 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 +178,7 @@ const EmbeddedDocumentForm = ({ draft = null, isPublished = false, onSave, isPai
Content -
- -
+
@@ -219,7 +212,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 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/index.js b/src/hooks/courses/index.js new file mode 100644 index 0000000..82bc2e2 --- /dev/null +++ b/src/hooks/courses/index.js @@ -0,0 +1,17 @@ +import useCourseDecryption from '../encryption/useCourseDecryption'; +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, + useCourseNavigation, + useCourseTabsState +}; \ 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..def9f34 --- /dev/null +++ b/src/hooks/courses/useCourseData.js @@ -0,0 +1,94 @@ +import { useState, useEffect } from 'react'; +import { nip19 } from 'nostr-tools'; +import { parseCourseEvent } from '@/utils/nostr'; +import { useToast } from '@/hooks/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; + let id; + + const fetchCourseId = async () => { + // 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; + } + 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 }; +}; + +export default useCourseData; \ No newline at end of file diff --git a/src/hooks/courses/useCourseNavigation.js b/src/hooks/courses/useCourseNavigation.js new file mode 100644 index 0000000..571718c --- /dev/null +++ b/src/hooks/courses/useCourseNavigation.js @@ -0,0 +1,88 @@ +import { useState, useEffect, useCallback } from 'react'; +import useCourseTabsState from './useCourseTabsState'; + +/** + * 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); + + // 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(() => { + 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, setActiveTab, setSidebarVisible]); + + // Function to handle lesson selection + const handleLessonSelect = useCallback((index) => { + setActiveIndex(index); + + // 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) { + setActiveTab('content'); + setSidebarVisible(false); + } + }, [router.query.slug, isMobileView, setActiveTab, setSidebarVisible]); + + // Function to toggle tab with lesson state integration + const toggleTab = useCallback((index) => { + const tabName = tabMap[index]; + setActiveTab(tabName); + + // Only show/hide sidebar on mobile - desktop keeps sidebar visible + if (isMobileView) { + setSidebarVisible(tabName === 'lessons'); + } + }, [tabMap, isMobileView, setActiveTab, setSidebarVisible]); + + return { + activeIndex, + setActiveIndex, + activeTab, + setActiveTab, + sidebarVisible, + setSidebarVisible, + handleLessonSelect, + toggleTab, + toggleSidebar: baseToggleSidebar, + getActiveTabIndex, + getTabItems, + tabMap + }; +}; + +export default useCourseNavigation; \ 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..c2740b8 --- /dev/null +++ b/src/hooks/courses/useCoursePayment.js @@ -0,0 +1,79 @@ +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?.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; + } + }, [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..f0ab071 --- /dev/null +++ b/src/hooks/courses/useCourseTabs.js @@ -0,0 +1,92 @@ +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 + * @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 = typeof windowWidth === 'number' ? windowWidth <= 968 : false; + + // 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, 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 parameters + setActiveTab('overview'); + } + } + }, [router.isReady, router.query, tabMap, setActiveTab]); + + // Toggle between tabs with router integration + 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'); + } + + // Sync URL with tab change using shallow routing + const newQuery = { + ...router.query, + tab: tabName === 'overview' ? undefined : tabName + }; + router.push( + { pathname: router.pathname, query: newQuery }, + undefined, + { shallow: true } + ); + }, [tabMap, isMobileView, router, setActiveTab, setSidebarVisible]); + + return { + activeTab, + setActiveTab, + sidebarVisible, + setSidebarVisible, + isMobileView, + toggleTab, + toggleSidebar, + getActiveTabIndex, + getTabItems, + tabMap + }; +}; + +export default useCourseTabs; \ No newline at end of file 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 diff --git a/src/hooks/courses/useLessons.js b/src/hooks/courses/useLessons.js new file mode 100644 index 0000000..2e98ea9 --- /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 (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 }; +}; + +export default useLessons; \ No newline at end of file diff --git a/src/hooks/encryption/useCourseDecryption.js b/src/hooks/encryption/useCourseDecryption.js new file mode 100644 index 0000000..b1b4b53 --- /dev/null +++ b/src/hooks/encryption/useCourseDecryption.js @@ -0,0 +1,165 @@ +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useDecryptContent } from './useDecryptContent'; + +const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, router, activeIndex = 0) => { + const [decryptedLessonIds, setDecryptedLessonIds] = useState({}); + const [loading, setLoading] = useState(false); + const { decryptContent } = useDecryptContent(); + 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 + const currentLessonIndex = activeIndex; + 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; + lastLessonIdRef.current = currentLessonId; + } + }, [currentLessonId, activeIndex]); + + // 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(() => { + if (decryptionPromise.cancel) { + decryptionPromise.cancel(); + } + reject(new Error('Decryption timeout')); + }, 10000); + decryptTimeoutRef.current = timeoutId; + }); + + // 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); + decryptTimeoutRef.current = null; + } catch (error) { + clearTimeout(timeoutId); + // If timeout or network error, schedule a retry + retryTimeoutRef.current = 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 + console.error('Decryption error:', error); + } finally { + setLoading(false); + processingRef.current = false; + } + }, [currentLesson, hasAccess, paidCourse, decryptContent, lessons, setLessons, decryptedLessonIds]); + + // Run decryption when lesson changes + useEffect(() => { + if (!currentLessonId) return; + + // Always attempt decryption when activeIndex changes + if (hasAccess && paidCourse && !decryptedLessonIds[currentLessonId]) { + decryptCurrentLesson(); + } + }, [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, + decryptedLessonIds + }; +}; + +export default useCourseDecryption; 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 } - +
+ +
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 b03be74..cd81817 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -1,314 +1,56 @@ -import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } 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'; -import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; -import appConfig from '@/config/appConfig'; +import { Buffer } from 'buffer'; + +// Hooks +import useCourseDecryption from '@/hooks/encryption/useCourseDecryption'; +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 }; -}; - -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 - }; -}; +// 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) { @@ -325,27 +67,43 @@ const Course = () => { } }, [router.isReady, router.query.slug, showToast, router]); + // Load completed lessons from localStorage when course is loaded 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'); + 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); + } } - - // Auto-open sidebar on desktop, close on mobile - setSidebarVisible(!isMobileView); } - }, [router.isReady, router.query, isMobileView]); + }, [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 => { @@ -371,15 +129,23 @@ const Course = () => { course?.pubkey ); - const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption( + const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useCourseDecryption( session, paidCourse, course, lessons, setLessons, - router + router, + activeIndex ); + // 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) { const addresses = {}; @@ -414,18 +180,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) { @@ -444,142 +199,7 @@ 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) { + if (courseLoading) { return (
@@ -587,45 +207,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 ( <>
@@ -640,45 +221,59 @@ 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])} + {isDecrypting || decryptionLoading ? ( +
+
+ +

Decrypting lesson content...

) : ( -
-

Select a lesson from the sidebar to begin learning.

-
- )} - - {course?.content && ( -
- -
+ )}
{/* QA tab content */}
- {renderQASection()} +
@@ -702,12 +297,7 @@ const Course = () => { { - handleLessonSelect(index); - if (isMobileView) { - setActiveTab('content'); // Use the tab name directly - } - }} + onLessonSelect={handleLessonSelect} completedLessons={completedLessons} isMobileView={isMobileView} sidebarVisible={sidebarVisible} @@ -723,17 +313,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}