mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-03 07:42:03 +00:00
Merge pull request #71 from AustinKelsay/refactor/courses-cleanup
Refactor/courses cleanup
This commit is contained in:
commit
e20bf4e961
65
package-lock.json
generated
65
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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({
|
@ -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);
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-start p-3 cursor-pointer ${isMobileView ? 'p-4' : 'p-3'}`}>
|
122
src/components/content/courses/tabs/CourseContent.js
Normal file
122
src/components/content/courses/tabs/CourseContent.js
Normal file
@ -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 (
|
||||
<CombinedLesson
|
||||
key={`combined-${lesson.id}`}
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
} else if (lesson.type === 'video' || lesson.topics?.includes('video')) {
|
||||
return (
|
||||
<VideoLesson
|
||||
key={`video-${lesson.id}`}
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
} else if (lesson.type === 'document' || lesson.topics?.includes('document')) {
|
||||
return (
|
||||
<DocumentLesson
|
||||
key={`doc-${lesson.id}`}
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{lessons.length > 0 && currentLesson ? (
|
||||
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||
<div
|
||||
key={`lesson-container-${currentLesson.id}`}
|
||||
className={`transition-opacity duration-300 ease-in-out ${isTransitioning ? 'opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
{renderLesson(currentLesson)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`text-center bg-gray-800 rounded-lg p-8 transition-opacity duration-300 ease-in-out ${isTransitioning ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<p>Select a lesson from the sidebar to begin learning.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{course?.content && (
|
||||
<div className="mt-8 bg-gray-800 rounded-lg shadow-sm">
|
||||
<MarkdownDisplay content={course.content} className="p-4 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseContent;
|
58
src/components/content/courses/tabs/CourseOverview.js
Normal file
58
src/components/content/courses/tabs/CourseOverview.js
Normal file
@ -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 (
|
||||
<div className={`bg-gray-800 rounded-lg border border-gray-800 shadow-md ${isMobileView ? 'p-4' : 'p-6'}`}>
|
||||
{isMobileView && course && (
|
||||
<div className="mb-2">
|
||||
{/* Completed tag above image in mobile view */}
|
||||
{isCompleted && (
|
||||
<div className="mb-2">
|
||||
<Tag severity="success" value="Completed" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Course image */}
|
||||
{course.image && (
|
||||
<div className="w-full h-48 relative rounded-lg overflow-hidden mb-3">
|
||||
<img
|
||||
src={course.image}
|
||||
alt={course.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<CourseDetails
|
||||
processedEvent={course}
|
||||
paidCourse={paidCourse}
|
||||
lessons={lessons}
|
||||
decryptionPerformed={decryptionPerformed}
|
||||
handlePaymentSuccess={handlePaymentSuccess}
|
||||
handlePaymentError={handlePaymentError}
|
||||
isMobileView={isMobileView}
|
||||
showCompletedTag={!isMobileView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseOverview;
|
32
src/components/content/courses/tabs/CourseQA.js
Normal file
32
src/components/content/courses/tabs/CourseQA.js
Normal file
@ -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 (
|
||||
<div className="rounded-lg p-8 mt-4 bg-gray-800 max-mob:px-4">
|
||||
<h2 className="text-xl font-bold mb-4">Comments</h2>
|
||||
{nAddress !== null && isAuthorized ? (
|
||||
<div className="px-4 max-mob:px-0">
|
||||
<ZapThreadsWrapper
|
||||
anchor={nAddress}
|
||||
user={nsec || npub || null}
|
||||
relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/"
|
||||
disable="zaps"
|
||||
isAuthorized={isAuthorized}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 mx-4 bg-gray-800/50 rounded-lg">
|
||||
<p className="text-gray-400">
|
||||
Comments are only available to content purchasers, subscribers, and the content creator.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseQA;
|
@ -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 = () => {
|
||||
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Content</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<MarkdownEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-col w-full">
|
||||
|
@ -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 }) => {
|
||||
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Content</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<MarkdownEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-col w-full">
|
||||
|
@ -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 }) => {
|
||||
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Video Embed</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={videoEmbed} onChange={handleVideoEmbedChange} height={250} />
|
||||
</div>
|
||||
<MarkdownEditor value={videoEmbed} onChange={handleVideoEmbedChange} height={250} />
|
||||
<small className="text-gray-400 mt-2">
|
||||
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 }) => {
|
||||
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Content</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<MarkdownEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-col w-full">
|
||||
|
@ -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
|
||||
</div>
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Content</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<MarkdownEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<div className="mt-8 flex-col w-full">
|
||||
<span className="pl-1 flex items-center">
|
||||
@ -219,7 +212,6 @@ const EmbeddedDocumentForm = ({ draft = null, isPublished = false, onSave, isPai
|
||||
<div className="w-full flex flex-row items-end justify-end py-2">
|
||||
<GenericButton icon="pi pi-plus" onClick={addAdditionalLink} />
|
||||
</div>
|
||||
<Tooltip target=".pi-info-circle" />
|
||||
</div>
|
||||
<div className="mt-8 flex-col w-full">
|
||||
{topics.map((topic, index) => (
|
||||
|
@ -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 = () => {
|
||||
</div>
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Content</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<MarkdownEditor
|
||||
value={content}
|
||||
onChange={handleContentChange}
|
||||
height={350}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-8 flex-col w-full">
|
||||
<span className="pl-1 flex items-center">
|
||||
|
@ -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 }) => {
|
||||
</div>
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Content</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<MarkdownEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<div className="mt-8 flex-col w-full">
|
||||
<span className="pl-1 flex items-center">
|
||||
|
@ -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 }) => {
|
||||
</div>
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Content</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<MarkdownEditor value={content} onChange={handleContentChange} height={350} />
|
||||
</div>
|
||||
<div className="mt-8 flex-col w-full">
|
||||
<span className="pl-1 flex items-center">
|
||||
|
@ -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 }) => {
|
||||
</div>
|
||||
<div className="p-inputgroup flex-1 flex-col mt-4">
|
||||
<span>Video Embed</span>
|
||||
<div data-color-mode="dark">
|
||||
<MDEditor value={videoEmbed} onChange={handleVideoEmbedChange} height={250} />
|
||||
</div>
|
||||
<MarkdownEditor value={videoEmbed} onChange={handleVideoEmbedChange} height={250} />
|
||||
<small className="text-gray-400 mt-2">
|
||||
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.
|
||||
|
128
src/components/markdown/MarkdownEditor.js
Normal file
128
src/components/markdown/MarkdownEditor.js
Normal file
@ -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 (
|
||||
<div data-color-mode="dark" className={`w-full ${className}`} style={{ colorScheme: 'dark' }}>
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
height={height}
|
||||
preview={preview}
|
||||
className="md-editor-dark"
|
||||
textareaProps={{
|
||||
placeholder,
|
||||
style: { color: "white" }
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
/* Force all text to white in editor */
|
||||
.w-md-editor * {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Reset preview text color */
|
||||
.w-md-editor-preview * {
|
||||
color: #c9d1d9 !important;
|
||||
}
|
||||
|
||||
/* Editor backgrounds */
|
||||
.md-editor-dark {
|
||||
background-color: #0d1117 !important;
|
||||
}
|
||||
|
||||
.w-md-editor-text-input {
|
||||
caret-color: white !important;
|
||||
-webkit-text-fill-color: white !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.w-md-editor-toolbar {
|
||||
background-color: #161b22 !important;
|
||||
border-bottom: 1px solid #30363d !important;
|
||||
}
|
||||
|
||||
/* Preview styling */
|
||||
.w-md-editor-preview {
|
||||
background-color: #0d1117 !important;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Make code blocks maintain their styling */
|
||||
.w-md-editor-preview pre {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #d4d4d4 !important;
|
||||
padding: 1em !important;
|
||||
border-radius: 5px !important;
|
||||
}
|
||||
|
||||
.w-md-editor-preview code {
|
||||
font-family: 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace !important;
|
||||
color: #d4d4d4 !important;
|
||||
}
|
||||
|
||||
/* Force anything with text-rendering to be white */
|
||||
[style*="text-rendering"] {
|
||||
color: white !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownEditor;
|
@ -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';
|
||||
|
17
src/hooks/courses/index.js
Normal file
17
src/hooks/courses/index.js
Normal file
@ -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
|
||||
};
|
94
src/hooks/courses/useCourseData.js
Normal file
94
src/hooks/courses/useCourseData.js
Normal file
@ -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;
|
88
src/hooks/courses/useCourseNavigation.js
Normal file
88
src/hooks/courses/useCourseNavigation.js
Normal file
@ -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;
|
79
src/hooks/courses/useCoursePayment.js
Normal file
79
src/hooks/courses/useCoursePayment.js
Normal file
@ -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;
|
92
src/hooks/courses/useCourseTabs.js
Normal file
92
src/hooks/courses/useCourseTabs.js
Normal file
@ -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;
|
140
src/hooks/courses/useCourseTabsState.js
Normal file
140
src/hooks/courses/useCourseTabsState.js
Normal file
@ -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;
|
61
src/hooks/courses/useLessons.js
Normal file
61
src/hooks/courses/useLessons.js
Normal file
@ -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;
|
165
src/hooks/encryption/useCourseDecryption.js
Normal file
165
src/hooks/encryption/useCourseDecryption.js
Normal file
@ -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;
|
@ -31,7 +31,9 @@ export default function MyApp({ Component, pageProps: { session, ...pageProps }
|
||||
<Component {...pageProps} />
|
||||
<Analytics />
|
||||
</main>
|
||||
<BottomBar />
|
||||
<div className="mt-12 min-bottom-bar:mt-0">
|
||||
<BottomBar />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</ToastProvider>
|
||||
|
@ -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';
|
||||
|
@ -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 (
|
||||
<div className="rounded-lg p-8 mt-4 bg-gray-800 max-mob:px-4">
|
||||
<h2 className="text-xl font-bold mb-4">Comments</h2>
|
||||
{nAddress !== null && isAuthorized ? (
|
||||
<div className="px-4 max-mob:px-0">
|
||||
<ZapThreadsWrapper
|
||||
anchor={nAddress}
|
||||
user={nsec || npub || null}
|
||||
relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/"
|
||||
disable="zaps"
|
||||
isAuthorized={isAuthorized}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 mx-4 bg-gray-800/50 rounded-lg">
|
||||
<p className="text-gray-400">
|
||||
Comments are only available to content purchasers, subscribers, and the content creator.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Render Course Overview section
|
||||
const renderOverviewSection = () => {
|
||||
// Get isCompleted status for use in the component
|
||||
const isCompleted = completedLessons.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-800 rounded-lg border border-gray-800 shadow-md ${isMobileView ? 'p-4' : 'p-6'}`}>
|
||||
{isMobileView && course && (
|
||||
<div className="mb-2">
|
||||
{/* Completed tag above image in mobile view */}
|
||||
{isCompleted && (
|
||||
<div className="mb-2">
|
||||
<Tag severity="success" value="Completed" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Course image */}
|
||||
{course.image && (
|
||||
<div className="w-full h-48 relative rounded-lg overflow-hidden mb-3">
|
||||
<img
|
||||
src={course.image}
|
||||
alt={course.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<CourseDetails
|
||||
processedEvent={course}
|
||||
paidCourse={paidCourse}
|
||||
lessons={uniqueLessons}
|
||||
decryptionPerformed={decryptionPerformed}
|
||||
handlePaymentSuccess={handlePaymentSuccess}
|
||||
handlePaymentError={handlePaymentError}
|
||||
isMobileView={isMobileView}
|
||||
showCompletedTag={!isMobileView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (courseLoading || decryptionLoading) {
|
||||
if (courseLoading) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<ProgressSpinner />
|
||||
@ -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 (
|
||||
<CombinedLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
|
||||
return (
|
||||
<VideoLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
} else if (lesson.type === 'document' && !lesson.topics?.includes('video')) {
|
||||
return (
|
||||
<DocumentLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto px-8 max-mob:px-0 mb-12 mt-2">
|
||||
@ -640,45 +221,59 @@ const Course = () => {
|
||||
activeIndex={getActiveTabIndex()}
|
||||
onTabChange={(index) => toggleTab(index)}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
isMobileView={isMobileView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Revised layout structure to prevent content flexing */}
|
||||
{/* Main content area with fixed width */}
|
||||
<div className="relative mt-4">
|
||||
{/* Main content area with fixed width */}
|
||||
<div className={`transition-all duration-500 ease-in-out ${isMobileView ? 'w-full' : 'w-full'}`}
|
||||
style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}>
|
||||
|
||||
{/* Overview tab content */}
|
||||
<div className={`${activeTab === 'overview' ? 'block' : 'hidden'}`}>
|
||||
{renderOverviewSection()}
|
||||
<CourseOverview
|
||||
course={course}
|
||||
paidCourse={paidCourse}
|
||||
lessons={uniqueLessons}
|
||||
decryptionPerformed={decryptionPerformed}
|
||||
handlePaymentSuccess={handlePaymentSuccess}
|
||||
handlePaymentError={handlePaymentError}
|
||||
isMobileView={isMobileView}
|
||||
completedLessons={completedLessons}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content tab content */}
|
||||
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
|
||||
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
|
||||
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||
<div key={`lesson-${uniqueLessons[activeIndex].id}`}>
|
||||
{renderLesson(uniqueLessons[activeIndex])}
|
||||
{isDecrypting || decryptionLoading ? (
|
||||
<div className="w-full py-12 bg-gray-800 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<ProgressSpinner style={{ width: '50px', height: '50px' }} />
|
||||
<p className="mt-4 text-gray-300">Decrypting lesson content...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center bg-gray-800 rounded-lg p-8">
|
||||
<p>Select a lesson from the sidebar to begin learning.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{course?.content && (
|
||||
<div className="mt-8 bg-gray-800 rounded-lg shadow-sm">
|
||||
<MarkdownDisplay content={course.content} className="p-4 rounded-lg" />
|
||||
</div>
|
||||
<CourseContent
|
||||
lessons={uniqueLessons}
|
||||
activeIndex={activeIndex}
|
||||
course={course}
|
||||
paidCourse={paidCourse}
|
||||
decryptedLessonIds={decryptedLessonIds}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QA tab content */}
|
||||
<div className={`${activeTab === 'qa' ? 'block' : 'hidden'}`}>
|
||||
{renderQASection()}
|
||||
<CourseQA
|
||||
nAddress={nAddress}
|
||||
isAuthorized={isAuthorized}
|
||||
nsec={nsec}
|
||||
npub={npub}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -702,12 +297,7 @@ const Course = () => {
|
||||
<CourseSidebar
|
||||
lessons={uniqueLessons}
|
||||
activeIndex={activeIndex}
|
||||
onLessonSelect={(index) => {
|
||||
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 = () => {
|
||||
<CourseSidebar
|
||||
lessons={uniqueLessons}
|
||||
activeIndex={activeIndex}
|
||||
onLessonSelect={(index) => {
|
||||
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}
|
||||
|
Loading…
x
Reference in New Issue
Block a user