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 && (
+
+

+
+ )}
+
+ )}
+
+
+ );
+};
+
+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 = () => {
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 }) => {
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 }) => {
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
@@ -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 = () => {
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 }) => {
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 }) => {
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 && (
-
-

-
- )}
-
- )}
-
-
- );
- };
-
- 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}