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",
"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",

View File

@ -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",

View File

@ -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({

View File

@ -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'}`}>

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 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">

View File

@ -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">

View File

@ -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">

View File

@ -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) => (

View File

@ -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">

View File

@ -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">

View File

@ -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">

View File

@ -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.

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

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} />
<Analytics />
</main>
<BottomBar />
<div className="mt-12 min-bottom-bar:mt-0">
<BottomBar />
</div>
</div>
</Layout>
</ToastProvider>

View File

@ -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';

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 { 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}