Merge pull request #71 from AustinKelsay/refactor/courses-cleanup

Refactor/courses cleanup
This commit is contained in:
Austin Kelsay 2025-05-12 12:24:26 -05:00 committed by GitHub
commit e20bf4e961
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1320 additions and 621 deletions

65
package-lock.json generated
View File

@ -29,6 +29,7 @@
"@vercel/kv": "^3.0.0", "@vercel/kv": "^3.0.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"bech32": "^2.0.0", "bech32": "^2.0.0",
"buffer": "^6.0.3",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -5555,6 +5556,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT" "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": { "node_modules/bcp-47-match": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", "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": "^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": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -8178,6 +8223,26 @@
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"license": "ISC" "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": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",

View File

@ -32,6 +32,7 @@
"@vercel/kv": "^3.0.0", "@vercel/kv": "^3.0.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"bech32": "^2.0.0", "bech32": "^2.0.0",
"buffer": "^6.0.3",
"chart.js": "^4.4.4", "chart.js": "^4.4.4",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@ -19,7 +19,7 @@ import { ProgressSpinner } from 'primereact/progressspinner';
import { Toast } from 'primereact/toast'; import { Toast } from 'primereact/toast';
// Import the desktop and mobile components // Import the desktop and mobile components
import DesktopCourseDetails from './DesktopCourseDetails'; import DesktopCourseDetails from '@/components/content/courses/details/DesktopCourseDetails';
import MobileCourseDetails from './MobileCourseDetails'; import MobileCourseDetails from './MobileCourseDetails';
export default function CourseDetails({ export default function CourseDetails({

View File

@ -37,8 +37,8 @@ const CourseSidebar = ({
${isMobileView ? 'mb-3' : 'mb-2'} ${isMobileView ? 'mb-3' : 'mb-2'}
`} `}
onClick={() => { onClick={() => {
// Force full page refresh to trigger proper decryption // Use smooth navigation function instead of forcing page refresh
window.location.href = `/course/${window.location.pathname.split('/').pop()}?active=${index}`; onLessonSelect(index);
}} }}
> >
<div className={`flex items-start p-3 cursor-pointer ${isMobileView ? 'p-4' : 'p-3'}`}> <div className={`flex items-start p-3 cursor-pointer ${isMobileView ? 'p-4' : 'p-3'}`}>

View 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;

View 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;

View 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;

View File

@ -1,19 +1,17 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useRouter } from 'next/router';
import { InputText } from 'primereact/inputtext'; import { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea'; import { InputTextarea } from 'primereact/inputtextarea';
import { InputNumber } from 'primereact/inputnumber'; import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch'; import { InputSwitch } from 'primereact/inputswitch';
import GenericButton from '@/components/buttons/GenericButton'; import GenericButton from '@/components/buttons/GenericButton';
import { useToast } from '@/hooks/useToast'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import dynamic from 'next/dynamic'; import { useToast } from '@/hooks/useToast';
import { Tooltip } from 'primereact/tooltip';
import 'primeicons/primeicons.css'; import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip';
import 'primereact/resources/primereact.min.css'; import 'primereact/resources/primereact.min.css';
import MarkdownEditor from '@/components/markdown/MarkdownEditor';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
const CDN_ENDPOINT = process.env.NEXT_PUBLIC_CDN_ENDPOINT; 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"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Content</span> <span>Content</span>
<div data-color-mode="dark"> <MarkdownEditor value={content} onChange={handleContentChange} height={350} />
<MDEditor value={content} onChange={handleContentChange} height={350} />
</div>
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">

View File

@ -1,19 +1,17 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useRouter } from 'next/router';
import { InputText } from 'primereact/inputtext'; import { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea'; import { InputTextarea } from 'primereact/inputtextarea';
import { InputNumber } from 'primereact/inputnumber'; import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch'; import { InputSwitch } from 'primereact/inputswitch';
import GenericButton from '@/components/buttons/GenericButton'; import GenericButton from '@/components/buttons/GenericButton';
import { useToast } from '@/hooks/useToast'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import dynamic from 'next/dynamic'; import { useToast } from '@/hooks/useToast';
import { Tooltip } from 'primereact/tooltip';
import 'primeicons/primeicons.css'; import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip';
import 'primereact/resources/primereact.min.css'; import 'primereact/resources/primereact.min.css';
import MarkdownEditor from '@/components/markdown/MarkdownEditor';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
const CDN_ENDPOINT = process.env.NEXT_PUBLIC_CDN_ENDPOINT; 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"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Content</span> <span>Content</span>
<div data-color-mode="dark"> <MarkdownEditor value={content} onChange={handleContentChange} height={350} />
<MDEditor value={content} onChange={handleContentChange} height={350} />
</div>
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">

View File

@ -1,23 +1,22 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; 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 { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea'; import { InputTextarea } from 'primereact/inputtextarea';
import { InputNumber } from 'primereact/inputnumber'; import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch'; 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 { useEncryptContent } from '@/hooks/encryption/useEncryptContent';
import MoreInfo from '@/components/MoreInfo'; import MoreInfo from '@/components/MoreInfo';
import dynamic from 'next/dynamic'; import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { import 'primereact/resources/primereact.min.css';
ssr: false, import MarkdownEditor from '@/components/markdown/MarkdownEditor';
});
const EditPublishedCombinedResourceForm = ({ event }) => { const EditPublishedCombinedResourceForm = ({ event }) => {
const router = useRouter(); const router = useRouter();
@ -220,9 +219,7 @@ const EditPublishedCombinedResourceForm = ({ event }) => {
<div className="p-inputgroup flex-1 flex-col mt-4"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Video Embed</span> <span>Video Embed</span>
<div data-color-mode="dark"> <MarkdownEditor value={videoEmbed} onChange={handleVideoEmbedChange} height={250} />
<MDEditor value={videoEmbed} onChange={handleVideoEmbedChange} height={250} />
</div>
<small className="text-gray-400 mt-2"> <small className="text-gray-400 mt-2">
You can customize your video embed using markdown or HTML. For example, paste iframe 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. 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"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Content</span> <span>Content</span>
<div data-color-mode="dark"> <MarkdownEditor value={content} onChange={handleContentChange} height={350} />
<MDEditor value={content} onChange={handleContentChange} height={350} />
</div>
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">

View File

@ -7,15 +7,10 @@ import { useSession } from 'next-auth/react';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { useNDKContext } from '@/context/NDKContext'; import { useNDKContext } from '@/context/NDKContext';
import { NDKEvent } from '@nostr-dev-kit/ndk'; 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 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip';
import 'primereact/resources/primereact.min.css'; 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 EmbeddedDocumentForm = ({ draft = null, isPublished = false, onSave, isPaid }) => {
const [title, setTitle] = useState(draft?.title || ''); const [title, setTitle] = useState(draft?.title || '');
@ -183,9 +178,7 @@ const EmbeddedDocumentForm = ({ draft = null, isPublished = false, onSave, isPai
</div> </div>
<div className="p-inputgroup flex-1 flex-col mt-4"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Content</span> <span>Content</span>
<div data-color-mode="dark"> <MarkdownEditor value={content} onChange={handleContentChange} height={350} />
<MDEditor value={content} onChange={handleContentChange} height={350} />
</div>
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">
<span className="pl-1 flex items-center"> <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"> <div className="w-full flex flex-row items-end justify-end py-2">
<GenericButton icon="pi pi-plus" onClick={addAdditionalLink} /> <GenericButton icon="pi pi-plus" onClick={addAdditionalLink} />
</div> </div>
<Tooltip target=".pi-info-circle" />
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">
{topics.map((topic, index) => ( {topics.map((topic, index) => (

View File

@ -8,14 +8,10 @@ import GenericButton from '@/components/buttons/GenericButton';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import dynamic from 'next/dynamic';
import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip'; import { Tooltip } from 'primereact/tooltip';
import 'primeicons/primeicons.css';
import 'primereact/resources/primereact.min.css'; import 'primereact/resources/primereact.min.css';
import MarkdownEditor from '@/components/markdown/MarkdownEditor';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), {
ssr: false,
});
const DocumentForm = () => { const DocumentForm = () => {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@ -149,9 +145,11 @@ const DocumentForm = () => {
</div> </div>
<div className="p-inputgroup flex-1 flex-col mt-4"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Content</span> <span>Content</span>
<div data-color-mode="dark"> <MarkdownEditor
<MDEditor value={content} onChange={handleContentChange} height={350} /> value={content}
</div> onChange={handleContentChange}
height={350}
/>
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">
<span className="pl-1 flex items-center"> <span className="pl-1 flex items-center">

View File

@ -8,10 +8,10 @@ import GenericButton from '@/components/buttons/GenericButton';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import dynamic from 'next/dynamic'; import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip'; 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 EditDraftDocumentForm = ({ draft }) => { const EditDraftDocumentForm = ({ draft }) => {
const [title, setTitle] = useState(draft?.title || ''); const [title, setTitle] = useState(draft?.title || '');
@ -143,9 +143,7 @@ const EditDraftDocumentForm = ({ draft }) => {
</div> </div>
<div className="p-inputgroup flex-1 flex-col mt-4"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Content</span> <span>Content</span>
<div data-color-mode="dark"> <MarkdownEditor value={content} onChange={handleContentChange} height={350} />
<MDEditor value={content} onChange={handleContentChange} height={350} />
</div>
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">
<span className="pl-1 flex items-center"> <span className="pl-1 flex items-center">

View File

@ -1,21 +1,21 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; 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 { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea'; import { InputTextarea } from 'primereact/inputtextarea';
import { InputNumber } from 'primereact/inputnumber'; import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch'; 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 { Tooltip } from 'primereact/tooltip';
import dynamic from 'next/dynamic'; import 'primereact/resources/primereact.min.css';
import MarkdownEditor from '@/components/markdown/MarkdownEditor';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { ssr: false });
const EditPublishedDocumentForm = ({ event }) => { const EditPublishedDocumentForm = ({ event }) => {
const router = useRouter(); const router = useRouter();
@ -198,9 +198,7 @@ const EditPublishedDocumentForm = ({ event }) => {
</div> </div>
<div className="p-inputgroup flex-1 flex-col mt-4"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Content</span> <span>Content</span>
<div data-color-mode="dark"> <MarkdownEditor value={content} onChange={handleContentChange} height={350} />
<MDEditor value={content} onChange={handleContentChange} height={350} />
</div>
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">
<span className="pl-1 flex items-center"> <span className="pl-1 flex items-center">

View File

@ -1,23 +1,22 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios'; 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 { InputText } from 'primereact/inputtext';
import { InputTextarea } from 'primereact/inputtextarea'; import { InputTextarea } from 'primereact/inputtextarea';
import { InputNumber } from 'primereact/inputnumber'; import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch'; 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 { useEncryptContent } from '@/hooks/encryption/useEncryptContent';
import MoreInfo from '@/components/MoreInfo'; import MoreInfo from '@/components/MoreInfo';
import dynamic from 'next/dynamic'; import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip';
const MDEditor = dynamic(() => import('@uiw/react-md-editor'), { import 'primereact/resources/primereact.min.css';
ssr: false, import MarkdownEditor from '@/components/markdown/MarkdownEditor';
});
const EditPublishedVideoForm = ({ event }) => { const EditPublishedVideoForm = ({ event }) => {
const router = useRouter(); const router = useRouter();
@ -190,9 +189,7 @@ const EditPublishedVideoForm = ({ event }) => {
</div> </div>
<div className="p-inputgroup flex-1 flex-col mt-4"> <div className="p-inputgroup flex-1 flex-col mt-4">
<span>Video Embed</span> <span>Video Embed</span>
<div data-color-mode="dark"> <MarkdownEditor value={videoEmbed} onChange={handleVideoEmbedChange} height={250} />
<MDEditor value={videoEmbed} onChange={handleVideoEmbedChange} height={250} />
</div>
<small className="text-gray-400 mt-2"> <small className="text-gray-400 mt-2">
You can customize your video embed using markdown or HTML. For example, paste iframe 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. embeds from YouTube or Vimeo, or use video tags for direct video files.

View 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;

View File

@ -8,7 +8,7 @@ import { useSession } from 'next-auth/react';
import 'primereact/resources/primereact.min.css'; import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css'; import 'primeicons/primeicons.css';
import useWindowWidth from '@/hooks/useWindowWidth'; import useWindowWidth from '@/hooks/useWindowWidth';
import CourseHeader from '../content/courses/CourseHeader'; import CourseHeader from '../content/courses/layout/CourseHeader';
import { useNDKContext } from '@/context/NDKContext'; import { useNDKContext } from '@/context/NDKContext';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { parseCourseEvent } from '@/utils/nostr'; import { parseCourseEvent } from '@/utils/nostr';

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

View 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 its 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -31,7 +31,9 @@ export default function MyApp({ Component, pageProps: { session, ...pageProps }
<Component {...pageProps} /> <Component {...pageProps} />
<Analytics /> <Analytics />
</main> </main>
<BottomBar /> <div className="mt-12 min-bottom-bar:mt-0">
<BottomBar />
</div>
</div> </div>
</Layout> </Layout>
</ToastProvider> </ToastProvider>

View File

@ -2,8 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import axios from 'axios'; import axios from 'axios';
import { parseEvent, findKind0Fields } from '@/utils/nostr'; import { parseEvent, findKind0Fields } from '@/utils/nostr';
import DraftCourseDetails from '@/components/content/courses/DraftCourseDetails'; import DraftCourseDetails from '@/components/content/courses/details/DraftCourseDetails';
import DraftCourseLesson from '@/components/content/courses/DraftCourseLesson'; import DraftCourseLesson from '@/components/content/courses/details/DraftCourseLesson';
import { useNDKContext } from '@/context/NDKContext'; import { useNDKContext } from '@/context/NDKContext';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useIsAdmin } from '@/hooks/useIsAdmin'; import { useIsAdmin } from '@/hooks/useIsAdmin';

View File

@ -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 { useRouter } from 'next/router';
import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr'; import { findKind0Fields } from '@/utils/nostr';
import CourseDetails from '@/components/content/courses/CourseDetails';
import VideoLesson from '@/components/content/courses/VideoLesson';
import DocumentLesson from '@/components/content/courses/DocumentLesson';
import CombinedLesson from '@/components/content/courses/CombinedLesson';
import CourseSidebar from '@/components/content/courses/CourseSidebar';
import { useNDKContext } from '@/context/NDKContext'; import { useNDKContext } from '@/context/NDKContext';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { ProgressSpinner } from 'primereact/progressspinner'; import { ProgressSpinner } from 'primereact/progressspinner';
import { useDecryptContent } from '@/hooks/encryption/useDecryptContent'; import { Buffer } from 'buffer';
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
import appConfig from '@/config/appConfig'; // 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'; 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 MenuTab from '@/components/menutab/MenuTab';
import { Tag } from 'primereact/tag';
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const useCourseData = (ndk, fetchAuthor, router) => { // Config
const [course, setCourse] = useState(null); import appConfig from '@/config/appConfig';
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
};
};
const Course = () => { const Course = () => {
const router = useRouter(); const router = useRouter();
const { ndk, addSigner } = useNDKContext(); const { ndk, addSigner } = useNDKContext();
const { data: session, update } = useSession(); const { data: session, update } = useSession();
const { showToast } = useToast(); const { showToast } = useToast();
const [activeIndex, setActiveIndex] = useState(0);
const [completedLessons, setCompletedLessons] = useState([]); const [completedLessons, setCompletedLessons] = useState([]);
const [nAddresses, setNAddresses] = useState({}); const [nAddresses, setNAddresses] = useState({});
const [nsec, setNsec] = useState(null); const [nsec, setNsec] = useState(null);
const [npub, setNpub] = useState(null); const [npub, setNpub] = useState(null);
const [sidebarVisible, setSidebarVisible] = useState(false);
const [nAddress, setNAddress] = useState(null); const [nAddress, setNAddress] = useState(null);
const windowWidth = useWindowWidth(); const windowWidth = useWindowWidth();
const isMobileView = windowWidth <= 968; const isMobileView = windowWidth <= 968;
const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab
const navbarHeight = 60; // Match the height from Navbar component const navbarHeight = 60; // Match the height from Navbar component
// Memoized function to get the tab map based on view mode // Use our navigation hook
const getTabMap = useMemo(() => { const {
const baseTabMap = ['overview', 'content', 'qa']; activeIndex,
if (isMobileView) { activeTab,
const mobileTabMap = [...baseTabMap]; sidebarVisible,
mobileTabMap.splice(2, 0, 'lessons'); setSidebarVisible,
return mobileTabMap; handleLessonSelect,
} toggleTab,
return baseTabMap; toggleSidebar,
}, [isMobileView]); getActiveTabIndex,
getTabItems,
} = useCourseNavigation(router, isMobileView);
useEffect(() => { useEffect(() => {
if (router.isReady && router.query.slug) { if (router.isReady && router.query.slug) {
@ -325,27 +67,43 @@ const Course = () => {
} }
}, [router.isReady, router.query.slug, showToast, router]); }, [router.isReady, router.query.slug, showToast, router]);
// Load completed lessons from localStorage when course is loaded
useEffect(() => { useEffect(() => {
if (router.isReady) { if (router.isReady && router.query.slug && session?.user) {
const { active } = router.query; const courseId = router.query.slug;
if (active !== undefined) { const storageKey = `course_${courseId}_${session.user.pubkey}_completed`;
setActiveIndex(parseInt(active, 10)); const savedCompletedLessons = localStorage.getItem(storageKey);
// If we have an active lesson, switch to content tab
setActiveTab('content'); if (savedCompletedLessons) {
} else { try {
setActiveIndex(0); const parsedLessons = JSON.parse(savedCompletedLessons);
// Default to overview tab when no active parameter setCompletedLessons(parsedLessons);
setActiveTab('overview'); } 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 => { 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( const fetchAuthor = useCallback(
async pubkey => { async pubkey => {
@ -371,15 +129,23 @@ const Course = () => {
course?.pubkey course?.pubkey
); );
const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption( const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useCourseDecryption(
session, session,
paidCourse, paidCourse,
course, course,
lessons, lessons,
setLessons, 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(() => { useEffect(() => {
if (uniqueLessons.length > 0) { if (uniqueLessons.length > 0) {
const addresses = {}; const addresses = {};
@ -414,18 +180,7 @@ const Course = () => {
session?.user?.role?.subscribed || session?.user?.role?.subscribed ||
session?.user?.pubkey === course?.pubkey || session?.user?.pubkey === course?.pubkey ||
!paidCourse || !paidCourse ||
session?.user?.purchased?.some(purchase => purchase.courseId === course?.d) session?.user?.purchased?.some(purchase => purchase.courseId === course?.d);
const handleLessonSelect = index => {
setActiveIndex(index);
router.push(`/course/${router.query.slug}?active=${index}`, undefined, { shallow: true });
// On mobile, switch to content tab after selection
if (isMobileView) {
setActiveTab('content');
setSidebarVisible(false);
}
};
const handlePaymentSuccess = async response => { const handlePaymentSuccess = async response => {
if (response && response?.preimage) { if (response && response?.preimage) {
@ -444,142 +199,7 @@ const Course = () => {
); );
}; };
const toggleTab = (index) => { if (courseLoading) {
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) {
return ( return (
<div className="w-full h-full flex items-center justify-center"> <div className="w-full h-full flex items-center justify-center">
<ProgressSpinner /> <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 ( return (
<> <>
<div className="mx-auto px-8 max-mob:px-0 mb-12 mt-2"> <div className="mx-auto px-8 max-mob:px-0 mb-12 mt-2">
@ -640,45 +221,59 @@ const Course = () => {
activeIndex={getActiveTabIndex()} activeIndex={getActiveTabIndex()}
onTabChange={(index) => toggleTab(index)} onTabChange={(index) => toggleTab(index)}
sidebarVisible={sidebarVisible} sidebarVisible={sidebarVisible}
onToggleSidebar={handleToggleSidebar} onToggleSidebar={toggleSidebar}
isMobileView={isMobileView} isMobileView={isMobileView}
/> />
</div> </div>
{/* Revised layout structure to prevent content flexing */} {/* Main content area with fixed width */}
<div className="relative mt-4"> <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'}`} <div className={`transition-all duration-500 ease-in-out ${isMobileView ? 'w-full' : 'w-full'}`}
style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}> style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}>
{/* Overview tab content */} {/* Overview tab content */}
<div className={`${activeTab === 'overview' ? 'block' : 'hidden'}`}> <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> </div>
{/* Content tab content */} {/* Content tab content */}
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}> <div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? ( {isDecrypting || decryptionLoading ? (
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden"> <div className="w-full py-12 bg-gray-800 rounded-lg flex items-center justify-center">
<div key={`lesson-${uniqueLessons[activeIndex].id}`}> <div className="text-center">
{renderLesson(uniqueLessons[activeIndex])} <ProgressSpinner style={{ width: '50px', height: '50px' }} />
<p className="mt-4 text-gray-300">Decrypting lesson content...</p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="text-center bg-gray-800 rounded-lg p-8"> <CourseContent
<p>Select a lesson from the sidebar to begin learning.</p> lessons={uniqueLessons}
</div> activeIndex={activeIndex}
)} course={course}
paidCourse={paidCourse}
{course?.content && ( decryptedLessonIds={decryptedLessonIds}
<div className="mt-8 bg-gray-800 rounded-lg shadow-sm"> setCompleted={setCompleted}
<MarkdownDisplay content={course.content} className="p-4 rounded-lg" /> />
</div>
)} )}
</div> </div>
{/* QA tab content */} {/* QA tab content */}
<div className={`${activeTab === 'qa' ? 'block' : 'hidden'}`}> <div className={`${activeTab === 'qa' ? 'block' : 'hidden'}`}>
{renderQASection()} <CourseQA
nAddress={nAddress}
isAuthorized={isAuthorized}
nsec={nsec}
npub={npub}
/>
</div> </div>
</div> </div>
@ -702,12 +297,7 @@ const Course = () => {
<CourseSidebar <CourseSidebar
lessons={uniqueLessons} lessons={uniqueLessons}
activeIndex={activeIndex} activeIndex={activeIndex}
onLessonSelect={(index) => { onLessonSelect={handleLessonSelect}
handleLessonSelect(index);
if (isMobileView) {
setActiveTab('content'); // Use the tab name directly
}
}}
completedLessons={completedLessons} completedLessons={completedLessons}
isMobileView={isMobileView} isMobileView={isMobileView}
sidebarVisible={sidebarVisible} sidebarVisible={sidebarVisible}
@ -723,17 +313,12 @@ const Course = () => {
<CourseSidebar <CourseSidebar
lessons={uniqueLessons} lessons={uniqueLessons}
activeIndex={activeIndex} activeIndex={activeIndex}
onLessonSelect={(index) => { onLessonSelect={handleLessonSelect}
handleLessonSelect(index);
if (isMobileView) {
setActiveTab('content'); // Use the tab name directly
}
}}
completedLessons={completedLessons} completedLessons={completedLessons}
isMobileView={isMobileView} isMobileView={isMobileView}
onClose={() => { onClose={() => {
setSidebarVisible(false); setSidebarVisible(false);
setActiveTab('content'); toggleTab(getActiveTabIndex());
}} }}
sidebarVisible={sidebarVisible} sidebarVisible={sidebarVisible}
setSidebarVisible={setSidebarVisible} setSidebarVisible={setSidebarVisible}