mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-05 00:32:03 +00:00
Merge pull request #71 from AustinKelsay/refactor/courses-cleanup
Refactor/courses cleanup
This commit is contained in:
commit
e20bf4e961
65
package-lock.json
generated
65
package-lock.json
generated
@ -29,6 +29,7 @@
|
|||||||
"@vercel/kv": "^3.0.0",
|
"@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",
|
||||||
|
@ -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",
|
||||||
|
@ -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({
|
@ -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'}`}>
|
122
src/components/content/courses/tabs/CourseContent.js
Normal file
122
src/components/content/courses/tabs/CourseContent.js
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import VideoLesson from '@/components/content/courses/lessons/VideoLesson';
|
||||||
|
import DocumentLesson from '@/components/content/courses/lessons/DocumentLesson';
|
||||||
|
import CombinedLesson from '@/components/content/courses/lessons/CombinedLesson';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display course content including lessons
|
||||||
|
*/
|
||||||
|
const CourseContent = ({
|
||||||
|
lessons,
|
||||||
|
activeIndex,
|
||||||
|
course,
|
||||||
|
paidCourse,
|
||||||
|
decryptedLessonIds,
|
||||||
|
setCompleted
|
||||||
|
}) => {
|
||||||
|
const [lastActiveIndex, setLastActiveIndex] = useState(activeIndex);
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||||
|
const [currentLesson, setCurrentLesson] = useState(null);
|
||||||
|
|
||||||
|
// Initialize current lesson and handle updates when lessons or activeIndex change
|
||||||
|
useEffect(() => {
|
||||||
|
if (lessons.length > 0 && activeIndex < lessons.length) {
|
||||||
|
setCurrentLesson(lessons[activeIndex]);
|
||||||
|
} else {
|
||||||
|
setCurrentLesson(null);
|
||||||
|
}
|
||||||
|
}, [lessons, activeIndex]);
|
||||||
|
|
||||||
|
// Handle smooth transitions between lessons
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeIndex !== lastActiveIndex) {
|
||||||
|
// Start transition
|
||||||
|
setIsTransitioning(true);
|
||||||
|
|
||||||
|
// After a short delay, update the current lesson
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setCurrentLesson(lessons[activeIndex] || null);
|
||||||
|
setLastActiveIndex(activeIndex);
|
||||||
|
|
||||||
|
// End transition with a slight delay to ensure content is ready
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsTransitioning(false);
|
||||||
|
}, 50);
|
||||||
|
}, 300); // Match this with CSS transition duration
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [activeIndex, lastActiveIndex, lessons]);
|
||||||
|
|
||||||
|
const renderLesson = (lesson) => {
|
||||||
|
if (!lesson) return null;
|
||||||
|
|
||||||
|
// Check if this specific lesson is decrypted
|
||||||
|
const lessonDecrypted = !paidCourse || decryptedLessonIds[lesson.id] || false;
|
||||||
|
|
||||||
|
if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) {
|
||||||
|
return (
|
||||||
|
<CombinedLesson
|
||||||
|
key={`combined-${lesson.id}`}
|
||||||
|
lesson={lesson}
|
||||||
|
course={course}
|
||||||
|
decryptionPerformed={lessonDecrypted}
|
||||||
|
isPaid={paidCourse}
|
||||||
|
setCompleted={setCompleted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (lesson.type === 'video' || lesson.topics?.includes('video')) {
|
||||||
|
return (
|
||||||
|
<VideoLesson
|
||||||
|
key={`video-${lesson.id}`}
|
||||||
|
lesson={lesson}
|
||||||
|
course={course}
|
||||||
|
decryptionPerformed={lessonDecrypted}
|
||||||
|
isPaid={paidCourse}
|
||||||
|
setCompleted={setCompleted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (lesson.type === 'document' || lesson.topics?.includes('document')) {
|
||||||
|
return (
|
||||||
|
<DocumentLesson
|
||||||
|
key={`doc-${lesson.id}`}
|
||||||
|
lesson={lesson}
|
||||||
|
course={course}
|
||||||
|
decryptionPerformed={lessonDecrypted}
|
||||||
|
isPaid={paidCourse}
|
||||||
|
setCompleted={setCompleted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{lessons.length > 0 && currentLesson ? (
|
||||||
|
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<div
|
||||||
|
key={`lesson-container-${currentLesson.id}`}
|
||||||
|
className={`transition-opacity duration-300 ease-in-out ${isTransitioning ? 'opacity-0' : 'opacity-100'}`}
|
||||||
|
>
|
||||||
|
{renderLesson(currentLesson)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`text-center bg-gray-800 rounded-lg p-8 transition-opacity duration-300 ease-in-out ${isTransitioning ? 'opacity-0' : 'opacity-100'}`}>
|
||||||
|
<p>Select a lesson from the sidebar to begin learning.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course?.content && (
|
||||||
|
<div className="mt-8 bg-gray-800 rounded-lg shadow-sm">
|
||||||
|
<MarkdownDisplay content={course.content} className="p-4 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseContent;
|
58
src/components/content/courses/tabs/CourseOverview.js
Normal file
58
src/components/content/courses/tabs/CourseOverview.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import CourseDetails from '../details/CourseDetails';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display course overview with details
|
||||||
|
*/
|
||||||
|
const CourseOverview = ({
|
||||||
|
course,
|
||||||
|
paidCourse,
|
||||||
|
lessons,
|
||||||
|
decryptionPerformed,
|
||||||
|
handlePaymentSuccess,
|
||||||
|
handlePaymentError,
|
||||||
|
isMobileView,
|
||||||
|
completedLessons
|
||||||
|
}) => {
|
||||||
|
// Determine if course is completed
|
||||||
|
const isCompleted = lessons && lessons.length > 0 && completedLessons.length === lessons.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800 rounded-lg border border-gray-800 shadow-md ${isMobileView ? 'p-4' : 'p-6'}`}>
|
||||||
|
{isMobileView && course && (
|
||||||
|
<div className="mb-2">
|
||||||
|
{/* Completed tag above image in mobile view */}
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<Tag severity="success" value="Completed" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Course image */}
|
||||||
|
{course.image && (
|
||||||
|
<div className="w-full h-48 relative rounded-lg overflow-hidden mb-3">
|
||||||
|
<img
|
||||||
|
src={course.image}
|
||||||
|
alt={course.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CourseDetails
|
||||||
|
processedEvent={course}
|
||||||
|
paidCourse={paidCourse}
|
||||||
|
lessons={lessons}
|
||||||
|
decryptionPerformed={decryptionPerformed}
|
||||||
|
handlePaymentSuccess={handlePaymentSuccess}
|
||||||
|
handlePaymentError={handlePaymentError}
|
||||||
|
isMobileView={isMobileView}
|
||||||
|
showCompletedTag={!isMobileView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseOverview;
|
32
src/components/content/courses/tabs/CourseQA.js
Normal file
32
src/components/content/courses/tabs/CourseQA.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display course comments and Q&A section
|
||||||
|
*/
|
||||||
|
const CourseQA = ({ nAddress, isAuthorized, nsec, npub }) => {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg p-8 mt-4 bg-gray-800 max-mob:px-4">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Comments</h2>
|
||||||
|
{nAddress !== null && isAuthorized ? (
|
||||||
|
<div className="px-4 max-mob:px-0">
|
||||||
|
<ZapThreadsWrapper
|
||||||
|
anchor={nAddress}
|
||||||
|
user={nsec || npub || null}
|
||||||
|
relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/"
|
||||||
|
disable="zaps"
|
||||||
|
isAuthorized={isAuthorized}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 mx-4 bg-gray-800/50 rounded-lg">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Comments are only available to content purchasers, subscribers, and the content creator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseQA;
|
@ -1,19 +1,17 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import 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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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) => (
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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.
|
||||||
|
128
src/components/markdown/MarkdownEditor.js
Normal file
128
src/components/markdown/MarkdownEditor.js
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import '@uiw/react-md-editor/markdown-editor.css';
|
||||||
|
import '@uiw/react-markdown-preview/markdown.css';
|
||||||
|
import 'github-markdown-css/github-markdown-dark.css';
|
||||||
|
|
||||||
|
// Custom theme for MDEditor
|
||||||
|
const mdEditorDarkTheme = {
|
||||||
|
markdown: '#fff',
|
||||||
|
markdownH1: '#fff',
|
||||||
|
markdownH2: '#fff',
|
||||||
|
markdownH3: '#fff',
|
||||||
|
markdownH4: '#fff',
|
||||||
|
markdownH5: '#fff',
|
||||||
|
markdownH6: '#fff',
|
||||||
|
markdownParagraph: '#fff',
|
||||||
|
markdownLink: '#58a6ff',
|
||||||
|
markdownCode: '#fff',
|
||||||
|
markdownList: '#fff',
|
||||||
|
markdownBlockquote: '#fff',
|
||||||
|
markdownTable: '#fff',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dynamically import MDEditor with custom theming
|
||||||
|
const MDEditor = dynamic(() => import('@uiw/react-md-editor').then(mod => {
|
||||||
|
// Override the module's default theme
|
||||||
|
if (mod.default) {
|
||||||
|
mod.default.Markdown = {
|
||||||
|
...mod.default.Markdown,
|
||||||
|
...mdEditorDarkTheme
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return mod;
|
||||||
|
}), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable markdown editor component with proper dark mode styling
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {string} props.value - The markdown content
|
||||||
|
* @param {Function} props.onChange - Callback function when content changes
|
||||||
|
* @param {number} props.height - Height of the editor (default: 300)
|
||||||
|
* @param {string} props.placeholder - Placeholder text for the editor
|
||||||
|
* @param {string} props.preview - Preview mode ('edit', 'preview', 'live') (default: 'edit')
|
||||||
|
* @param {string} props.className - Additional class names
|
||||||
|
* @returns {JSX.Element}
|
||||||
|
*/
|
||||||
|
const MarkdownEditor = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
height = 300,
|
||||||
|
placeholder = "Write your content here...",
|
||||||
|
preview = "edit",
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div data-color-mode="dark" className={`w-full ${className}`} style={{ colorScheme: 'dark' }}>
|
||||||
|
<MDEditor
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
height={height}
|
||||||
|
preview={preview}
|
||||||
|
className="md-editor-dark"
|
||||||
|
textareaProps={{
|
||||||
|
placeholder,
|
||||||
|
style: { color: "white" }
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<style jsx global>{`
|
||||||
|
/* Force all text to white in editor */
|
||||||
|
.w-md-editor * {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset preview text color */
|
||||||
|
.w-md-editor-preview * {
|
||||||
|
color: #c9d1d9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor backgrounds */
|
||||||
|
.md-editor-dark {
|
||||||
|
background-color: #0d1117 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-text-input {
|
||||||
|
caret-color: white !important;
|
||||||
|
-webkit-text-fill-color: white !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-toolbar {
|
||||||
|
background-color: #161b22 !important;
|
||||||
|
border-bottom: 1px solid #30363d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preview styling */
|
||||||
|
.w-md-editor-preview {
|
||||||
|
background-color: #0d1117 !important;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make code blocks maintain their styling */
|
||||||
|
.w-md-editor-preview pre {
|
||||||
|
background-color: #1e1e1e !important;
|
||||||
|
color: #d4d4d4 !important;
|
||||||
|
padding: 1em !important;
|
||||||
|
border-radius: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-md-editor-preview code {
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace !important;
|
||||||
|
color: #d4d4d4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force anything with text-rendering to be white */
|
||||||
|
[style*="text-rendering"] {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkdownEditor;
|
@ -8,7 +8,7 @@ import { useSession } from 'next-auth/react';
|
|||||||
import 'primereact/resources/primereact.min.css';
|
import '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';
|
||||||
|
17
src/hooks/courses/index.js
Normal file
17
src/hooks/courses/index.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import useCourseDecryption from '../encryption/useCourseDecryption';
|
||||||
|
import useCourseTabs from './useCourseTabs';
|
||||||
|
import useCoursePayment from './useCoursePayment';
|
||||||
|
import useCourseData from './useCourseData';
|
||||||
|
import useLessons from './useLessons';
|
||||||
|
import useCourseNavigation from './useCourseNavigation';
|
||||||
|
import useCourseTabsState from './useCourseTabsState';
|
||||||
|
|
||||||
|
export {
|
||||||
|
useCourseDecryption,
|
||||||
|
useCourseTabs,
|
||||||
|
useCoursePayment,
|
||||||
|
useCourseData,
|
||||||
|
useLessons,
|
||||||
|
useCourseNavigation,
|
||||||
|
useCourseTabsState
|
||||||
|
};
|
94
src/hooks/courses/useCourseData.js
Normal file
94
src/hooks/courses/useCourseData.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { parseCourseEvent } from '@/utils/nostr';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch and manage course data
|
||||||
|
* @param {Object} ndk - NDK instance for Nostr data fetching
|
||||||
|
* @param {Function} fetchAuthor - Function to fetch author data
|
||||||
|
* @param {Object} router - Next.js router instance
|
||||||
|
* @returns {Object} Course data and related state
|
||||||
|
*/
|
||||||
|
const useCourseData = (ndk, fetchAuthor, router) => {
|
||||||
|
const [course, setCourse] = useState(null);
|
||||||
|
const [lessonIds, setLessonIds] = useState([]);
|
||||||
|
const [paidCourse, setPaidCourse] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) return;
|
||||||
|
|
||||||
|
const { slug } = router.query;
|
||||||
|
let id;
|
||||||
|
|
||||||
|
const fetchCourseId = async () => {
|
||||||
|
// Normalise slug to a string and exit early if it’s still falsy
|
||||||
|
const slugStr = Array.isArray(slug) ? slug[0] : slug;
|
||||||
|
if (!slugStr) {
|
||||||
|
showToast('error', 'Error', 'Invalid course identifier');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slugStr.includes('naddr')) {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
({ data } = nip19.decode(slugStr));
|
||||||
|
} catch (err) {
|
||||||
|
showToast('error', 'Error', 'Malformed naddr');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data?.identifier) {
|
||||||
|
showToast('error', 'Error', 'Resource not found');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.identifier;
|
||||||
|
} else {
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCourse = async courseId => {
|
||||||
|
try {
|
||||||
|
await ndk.connect();
|
||||||
|
const event = await ndk.fetchEvent({ '#d': [courseId] });
|
||||||
|
if (!event) return null;
|
||||||
|
|
||||||
|
const author = await fetchAuthor(event.pubkey);
|
||||||
|
const lessonIds = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1].split(':')[2]);
|
||||||
|
|
||||||
|
const parsedCourse = { ...parseCourseEvent(event), author };
|
||||||
|
return { parsedCourse, lessonIds };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching event:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeCourse = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
id = await fetchCourseId();
|
||||||
|
if (!id) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courseData = await fetchCourse(id);
|
||||||
|
if (courseData) {
|
||||||
|
const { parsedCourse, lessonIds } = courseData;
|
||||||
|
setCourse(parsedCourse);
|
||||||
|
setLessonIds(lessonIds);
|
||||||
|
setPaidCourse(parsedCourse.price && parsedCourse.price > 0);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeCourse();
|
||||||
|
}, [router.isReady, router.query, ndk, fetchAuthor, showToast]);
|
||||||
|
|
||||||
|
return { course, lessonIds, paidCourse, loading };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCourseData;
|
88
src/hooks/courses/useCourseNavigation.js
Normal file
88
src/hooks/courses/useCourseNavigation.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import useCourseTabsState from './useCourseTabsState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage course navigation and tab logic
|
||||||
|
* @param {Object} router - Next.js router instance
|
||||||
|
* @param {Boolean} isMobileView - Whether the current view is mobile
|
||||||
|
* @returns {Object} Navigation state and functions
|
||||||
|
*/
|
||||||
|
const useCourseNavigation = (router, isMobileView) => {
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
// Use the base hook for core tab state functionality
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
sidebarVisible,
|
||||||
|
setSidebarVisible,
|
||||||
|
tabMap,
|
||||||
|
getActiveTabIndex,
|
||||||
|
getTabItems,
|
||||||
|
toggleSidebar: baseToggleSidebar
|
||||||
|
} = useCourseTabsState({
|
||||||
|
isMobileView
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize navigation state based on router
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.isReady) {
|
||||||
|
const { active } = router.query;
|
||||||
|
if (active !== undefined) {
|
||||||
|
setActiveIndex(parseInt(active, 10));
|
||||||
|
// If we have an active lesson, switch to content tab
|
||||||
|
setActiveTab('content');
|
||||||
|
} else {
|
||||||
|
setActiveIndex(0);
|
||||||
|
// Default to overview tab when no active parameter
|
||||||
|
setActiveTab('overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-open sidebar on desktop, close on mobile
|
||||||
|
setSidebarVisible(!isMobileView);
|
||||||
|
}
|
||||||
|
}, [router.isReady, router.query, isMobileView, setActiveTab, setSidebarVisible]);
|
||||||
|
|
||||||
|
// Function to handle lesson selection
|
||||||
|
const handleLessonSelect = useCallback((index) => {
|
||||||
|
setActiveIndex(index);
|
||||||
|
|
||||||
|
// Update URL without causing a page reload (for bookmarking purposes)
|
||||||
|
const newUrl = `/course/${router.query.slug}?active=${index}`;
|
||||||
|
window.history.replaceState({ url: newUrl, as: newUrl, options: { shallow: true } }, '', newUrl);
|
||||||
|
|
||||||
|
// On mobile, switch to content tab after selection
|
||||||
|
if (isMobileView) {
|
||||||
|
setActiveTab('content');
|
||||||
|
setSidebarVisible(false);
|
||||||
|
}
|
||||||
|
}, [router.query.slug, isMobileView, setActiveTab, setSidebarVisible]);
|
||||||
|
|
||||||
|
// Function to toggle tab with lesson state integration
|
||||||
|
const toggleTab = useCallback((index) => {
|
||||||
|
const tabName = tabMap[index];
|
||||||
|
setActiveTab(tabName);
|
||||||
|
|
||||||
|
// Only show/hide sidebar on mobile - desktop keeps sidebar visible
|
||||||
|
if (isMobileView) {
|
||||||
|
setSidebarVisible(tabName === 'lessons');
|
||||||
|
}
|
||||||
|
}, [tabMap, isMobileView, setActiveTab, setSidebarVisible]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeIndex,
|
||||||
|
setActiveIndex,
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
sidebarVisible,
|
||||||
|
setSidebarVisible,
|
||||||
|
handleLessonSelect,
|
||||||
|
toggleTab,
|
||||||
|
toggleSidebar: baseToggleSidebar,
|
||||||
|
getActiveTabIndex,
|
||||||
|
getTabItems,
|
||||||
|
tabMap
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCourseNavigation;
|
79
src/hooks/courses/useCoursePayment.js
Normal file
79
src/hooks/courses/useCoursePayment.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useToast } from '../useToast';
|
||||||
|
import { useSession } from 'next-auth/react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle course payment processing and authorization
|
||||||
|
* @param {Object} course - The course object
|
||||||
|
* @returns {Object} Payment handling utilities and authorization state
|
||||||
|
*/
|
||||||
|
const useCoursePayment = (course) => {
|
||||||
|
const { data: session, update } = useSession();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
// Determine if course requires payment
|
||||||
|
const isPaidCourse = useMemo(() => {
|
||||||
|
return course?.price && course.price > 0;
|
||||||
|
}, [course]);
|
||||||
|
|
||||||
|
// Check if user is authorized to access the course
|
||||||
|
const isAuthorized = useMemo(() => {
|
||||||
|
if (!session?.user || !course) return !isPaidCourse; // Free courses are always authorized
|
||||||
|
|
||||||
|
return (
|
||||||
|
// User is subscribed
|
||||||
|
session.user.role?.subscribed ||
|
||||||
|
// User is the creator of the course
|
||||||
|
session.user.pubkey === course.pubkey ||
|
||||||
|
// Course is free
|
||||||
|
!isPaidCourse ||
|
||||||
|
// User has purchased this specific course
|
||||||
|
session.user.purchased?.some(purchase => purchase.courseId === course.d)
|
||||||
|
);
|
||||||
|
}, [session, course, isPaidCourse]);
|
||||||
|
|
||||||
|
// Handler for successful payment
|
||||||
|
const handlePaymentSuccess = useCallback(async (response) => {
|
||||||
|
if (response?.preimage) {
|
||||||
|
try {
|
||||||
|
await update(); // refresh session
|
||||||
|
showToast(
|
||||||
|
'success',
|
||||||
|
'Payment Success',
|
||||||
|
'You have successfully purchased this course'
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
showToast(
|
||||||
|
'warn',
|
||||||
|
'Session Refresh Failed',
|
||||||
|
'Purchase succeeded but we could not refresh your session automatically. Please reload the page.'
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('error', 'Error', 'Failed to purchase course. Please try again.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [update, showToast]);
|
||||||
|
|
||||||
|
// Handler for payment errors
|
||||||
|
const handlePaymentError = useCallback((error) => {
|
||||||
|
showToast(
|
||||||
|
'error',
|
||||||
|
'Payment Error',
|
||||||
|
`Failed to purchase course. Please try again. Error: ${error}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPaidCourse,
|
||||||
|
isAuthorized,
|
||||||
|
handlePaymentSuccess,
|
||||||
|
handlePaymentError,
|
||||||
|
session
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCoursePayment;
|
92
src/hooks/courses/useCourseTabs.js
Normal file
92
src/hooks/courses/useCourseTabs.js
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import useWindowWidth from '../useWindowWidth';
|
||||||
|
import useCourseTabsState from './useCourseTabsState';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use useCourseTabsState for pure state or useCourseNavigation for router integration
|
||||||
|
* Hook to manage course tabs, navigation, and sidebar visibility
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {Array} options.tabMap - Optional custom tab map to use
|
||||||
|
* @param {boolean} options.initialSidebarVisible - Initial sidebar visibility state
|
||||||
|
* @returns {Object} Tab management utilities and state
|
||||||
|
*/
|
||||||
|
const useCourseTabs = (options = {}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const windowWidth = useWindowWidth();
|
||||||
|
const isMobileView = typeof windowWidth === 'number' ? windowWidth <= 968 : false;
|
||||||
|
|
||||||
|
// Use the base hook for core tab state functionality
|
||||||
|
const {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
sidebarVisible,
|
||||||
|
setSidebarVisible,
|
||||||
|
tabMap,
|
||||||
|
getActiveTabIndex,
|
||||||
|
getTabItems,
|
||||||
|
toggleSidebar
|
||||||
|
} = useCourseTabsState({
|
||||||
|
tabMap: options.tabMap,
|
||||||
|
initialSidebarVisible: options.initialSidebarVisible,
|
||||||
|
isMobileView
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update tabs and sidebar based on router query
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.isReady) {
|
||||||
|
const { active, tab } = router.query;
|
||||||
|
|
||||||
|
// If tab is specified in the URL, use that
|
||||||
|
if (tab && tabMap.includes(tab)) {
|
||||||
|
setActiveTab(tab);
|
||||||
|
} else if (active !== undefined) {
|
||||||
|
// If we have an active lesson, switch to content tab
|
||||||
|
setActiveTab('content');
|
||||||
|
} else {
|
||||||
|
// Default to overview tab when no parameters
|
||||||
|
setActiveTab('overview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [router.isReady, router.query, tabMap, setActiveTab]);
|
||||||
|
|
||||||
|
// Toggle between tabs with router integration
|
||||||
|
const toggleTab = useCallback((indexOrName) => {
|
||||||
|
const tabName = typeof indexOrName === 'number'
|
||||||
|
? tabMap[indexOrName]
|
||||||
|
: indexOrName;
|
||||||
|
|
||||||
|
setActiveTab(tabName);
|
||||||
|
|
||||||
|
// Only show/hide sidebar on mobile - desktop keeps sidebar visible
|
||||||
|
if (isMobileView) {
|
||||||
|
setSidebarVisible(tabName === 'lessons');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync URL with tab change using shallow routing
|
||||||
|
const newQuery = {
|
||||||
|
...router.query,
|
||||||
|
tab: tabName === 'overview' ? undefined : tabName
|
||||||
|
};
|
||||||
|
router.push(
|
||||||
|
{ pathname: router.pathname, query: newQuery },
|
||||||
|
undefined,
|
||||||
|
{ shallow: true }
|
||||||
|
);
|
||||||
|
}, [tabMap, isMobileView, router, setActiveTab, setSidebarVisible]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
sidebarVisible,
|
||||||
|
setSidebarVisible,
|
||||||
|
isMobileView,
|
||||||
|
toggleTab,
|
||||||
|
toggleSidebar,
|
||||||
|
getActiveTabIndex,
|
||||||
|
getTabItems,
|
||||||
|
tabMap
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCourseTabs;
|
140
src/hooks/courses/useCourseTabsState.js
Normal file
140
src/hooks/courses/useCourseTabsState.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base hook for tab state management with no router or side-effects
|
||||||
|
* This pure hook manages the tab state and sidebar visibility
|
||||||
|
*
|
||||||
|
* @param {Object} options - Configuration options
|
||||||
|
* @param {Array} options.tabMap - Optional custom tab map to use
|
||||||
|
* @param {boolean} options.initialSidebarVisible - Initial sidebar visibility state
|
||||||
|
* @param {boolean} options.isMobileView - Whether the current view is mobile
|
||||||
|
* @returns {Object} Pure tab management utilities and state
|
||||||
|
*/
|
||||||
|
const useCourseTabsState = (options = {}) => {
|
||||||
|
const {
|
||||||
|
tabMap: customTabMap,
|
||||||
|
initialSidebarVisible,
|
||||||
|
isMobileView = false
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Tab management state
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
const [sidebarVisible, setSidebarVisible] = useState(
|
||||||
|
initialSidebarVisible !== undefined ? initialSidebarVisible : !isMobileView
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track if we've initialized yet
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
// Get tab map based on view mode
|
||||||
|
const tabMap = useMemo(() => {
|
||||||
|
const baseTabMap = customTabMap || ['overview', 'content', 'qa'];
|
||||||
|
if (isMobileView) {
|
||||||
|
const mobileTabMap = [...baseTabMap];
|
||||||
|
// Insert lessons tab before qa in mobile view
|
||||||
|
if (!mobileTabMap.includes('lessons')) {
|
||||||
|
mobileTabMap.splice(2, 0, 'lessons');
|
||||||
|
}
|
||||||
|
return mobileTabMap;
|
||||||
|
}
|
||||||
|
return baseTabMap;
|
||||||
|
}, [isMobileView, customTabMap]);
|
||||||
|
|
||||||
|
// Auto-update sidebar visibility based on mobile/desktop
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized.current) {
|
||||||
|
// Only auto-update sidebar visibility if we're initialized
|
||||||
|
// and the view mode changes
|
||||||
|
setSidebarVisible(!isMobileView);
|
||||||
|
} else {
|
||||||
|
initialized.current = true;
|
||||||
|
}
|
||||||
|
}, [isMobileView]);
|
||||||
|
|
||||||
|
// Get active tab index
|
||||||
|
const getActiveTabIndex = useCallback(() => {
|
||||||
|
return tabMap.indexOf(activeTab);
|
||||||
|
}, [activeTab, tabMap]);
|
||||||
|
|
||||||
|
// Pure toggle between tabs with no side effects
|
||||||
|
const toggleTab = useCallback((indexOrName) => {
|
||||||
|
const tabName = typeof indexOrName === 'number'
|
||||||
|
? tabMap[indexOrName]
|
||||||
|
: indexOrName;
|
||||||
|
|
||||||
|
setActiveTab(tabName);
|
||||||
|
|
||||||
|
// Only show/hide sidebar on mobile - desktop keeps sidebar visible
|
||||||
|
if (isMobileView) {
|
||||||
|
setSidebarVisible(tabName === 'lessons');
|
||||||
|
}
|
||||||
|
}, [tabMap, isMobileView]);
|
||||||
|
|
||||||
|
// Toggle sidebar visibility
|
||||||
|
const toggleSidebar = useCallback(() => {
|
||||||
|
setSidebarVisible(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Generate tab items for MenuTab component
|
||||||
|
const getTabItems = useCallback(() => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
icon: 'pi pi-home',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content',
|
||||||
|
icon: 'pi pi-book',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add lessons tab only on mobile
|
||||||
|
if (isMobileView) {
|
||||||
|
items.push({
|
||||||
|
label: 'Lessons',
|
||||||
|
icon: 'pi pi-list',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: 'Comments',
|
||||||
|
icon: 'pi pi-comments',
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [isMobileView]);
|
||||||
|
|
||||||
|
// Setup keyboard navigation for tabs
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
const currentIndex = getActiveTabIndex();
|
||||||
|
const nextIndex = (currentIndex + 1) % tabMap.length;
|
||||||
|
toggleTab(nextIndex);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
const currentIndex = getActiveTabIndex();
|
||||||
|
const prevIndex = (currentIndex - 1 + tabMap.length) % tabMap.length;
|
||||||
|
toggleTab(prevIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [getActiveTabIndex, tabMap, toggleTab]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab,
|
||||||
|
setActiveTab,
|
||||||
|
sidebarVisible,
|
||||||
|
setSidebarVisible,
|
||||||
|
toggleTab,
|
||||||
|
toggleSidebar,
|
||||||
|
getActiveTabIndex,
|
||||||
|
getTabItems,
|
||||||
|
tabMap
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCourseTabsState;
|
61
src/hooks/courses/useLessons.js
Normal file
61
src/hooks/courses/useLessons.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { parseEvent } from '@/utils/nostr';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch and manage lesson data for a course
|
||||||
|
* @param {Object} ndk - NDK instance for Nostr data fetching
|
||||||
|
* @param {Function} fetchAuthor - Function to fetch author data
|
||||||
|
* @param {Array} lessonIds - Array of lesson IDs to fetch
|
||||||
|
* @param {String} pubkey - Public key of the course author
|
||||||
|
* @returns {Object} Lesson data and state
|
||||||
|
*/
|
||||||
|
const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
||||||
|
const [lessons, setLessons] = useState([]);
|
||||||
|
const [uniqueLessons, setUniqueLessons] = useState([]);
|
||||||
|
|
||||||
|
// Fetch lessons when IDs or pubkey change
|
||||||
|
useEffect(() => {
|
||||||
|
if (lessonIds.length > 0 && pubkey) {
|
||||||
|
const fetchLessons = async () => {
|
||||||
|
try {
|
||||||
|
await ndk.connect();
|
||||||
|
|
||||||
|
// Create a single filter with all lesson IDs to avoid multiple calls
|
||||||
|
const filter = {
|
||||||
|
'#d': lessonIds,
|
||||||
|
kinds: [30023, 30402],
|
||||||
|
authors: [pubkey],
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = await ndk.fetchEvents(filter);
|
||||||
|
const newLessons = [];
|
||||||
|
|
||||||
|
// Process events (no need to check for duplicates here)
|
||||||
|
for (const event of events) {
|
||||||
|
const author = await fetchAuthor(event.pubkey);
|
||||||
|
const parsedLesson = { ...parseEvent(event), author };
|
||||||
|
newLessons.push(parsedLesson);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLessons(newLessons);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching events:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchLessons();
|
||||||
|
}
|
||||||
|
}, [lessonIds, ndk, fetchAuthor, pubkey]);
|
||||||
|
|
||||||
|
// Keep this deduplication logic using Map
|
||||||
|
useEffect(() => {
|
||||||
|
const newUniqueLessons = Array.from(
|
||||||
|
new Map(lessons.map(lesson => [lesson.id, lesson])).values()
|
||||||
|
);
|
||||||
|
setUniqueLessons(newUniqueLessons);
|
||||||
|
}, [lessons]);
|
||||||
|
|
||||||
|
return { lessons, uniqueLessons, setLessons };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useLessons;
|
165
src/hooks/encryption/useCourseDecryption.js
Normal file
165
src/hooks/encryption/useCourseDecryption.js
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useDecryptContent } from './useDecryptContent';
|
||||||
|
|
||||||
|
const useCourseDecryption = (session, paidCourse, course, lessons, setLessons, router, activeIndex = 0) => {
|
||||||
|
const [decryptedLessonIds, setDecryptedLessonIds] = useState({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { decryptContent } = useDecryptContent();
|
||||||
|
const processingRef = useRef(false);
|
||||||
|
const lastLessonIdRef = useRef(null);
|
||||||
|
const retryCountRef = useRef({});
|
||||||
|
const retryTimeoutRef = useRef(null);
|
||||||
|
const decryptTimeoutRef = useRef(null);
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
// Get the current active lesson using the activeIndex prop instead of router.query
|
||||||
|
const currentLessonIndex = activeIndex;
|
||||||
|
const currentLesson = lessons.length > 0 ? lessons[currentLessonIndex] : null;
|
||||||
|
const currentLessonId = currentLesson?.id;
|
||||||
|
|
||||||
|
// Check if the current lesson has been decrypted
|
||||||
|
const isCurrentLessonDecrypted =
|
||||||
|
!paidCourse ||
|
||||||
|
(currentLessonId && decryptedLessonIds[currentLessonId]);
|
||||||
|
|
||||||
|
// Check user access
|
||||||
|
const hasAccess = useMemo(() => {
|
||||||
|
if (!session?.user || !paidCourse || !course) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.user.purchased?.some(purchase => purchase.courseId === course?.d) ||
|
||||||
|
session.user?.role?.subscribed ||
|
||||||
|
session.user?.pubkey === course?.pubkey
|
||||||
|
);
|
||||||
|
}, [session, paidCourse, course]);
|
||||||
|
|
||||||
|
// Reset retry count when lesson changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentLessonId && lastLessonIdRef.current !== currentLessonId) {
|
||||||
|
retryCountRef.current[currentLessonId] = 0;
|
||||||
|
lastLessonIdRef.current = currentLessonId;
|
||||||
|
}
|
||||||
|
}, [currentLessonId, activeIndex]);
|
||||||
|
|
||||||
|
// Simplified decrypt function
|
||||||
|
const decryptCurrentLesson = useCallback(async () => {
|
||||||
|
if (!currentLesson || !hasAccess || !paidCourse) return;
|
||||||
|
if (processingRef.current) return;
|
||||||
|
if (decryptedLessonIds[currentLesson.id]) return;
|
||||||
|
if (!currentLesson.content) return;
|
||||||
|
|
||||||
|
// Check retry count
|
||||||
|
if (!retryCountRef.current[currentLesson.id]) {
|
||||||
|
retryCountRef.current[currentLesson.id] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit maximum retries
|
||||||
|
if (retryCountRef.current[currentLesson.id] >= MAX_RETRIES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment retry count
|
||||||
|
retryCountRef.current[currentLesson.id]++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
processingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Start the decryption process
|
||||||
|
const decryptionPromise = decryptContent(currentLesson.content);
|
||||||
|
|
||||||
|
// Add safety timeout to prevent infinite processing
|
||||||
|
let timeoutId;
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
if (decryptionPromise.cancel) {
|
||||||
|
decryptionPromise.cancel();
|
||||||
|
}
|
||||||
|
reject(new Error('Decryption timeout'));
|
||||||
|
}, 10000);
|
||||||
|
decryptTimeoutRef.current = timeoutId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a separate try-catch for the race
|
||||||
|
let decryptedContent;
|
||||||
|
try {
|
||||||
|
// Race between decryption and timeout
|
||||||
|
decryptedContent = await Promise.race([
|
||||||
|
decryptionPromise,
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Clear the timeout if decryption wins
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
decryptTimeoutRef.current = null;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
// If timeout or network error, schedule a retry
|
||||||
|
retryTimeoutRef.current = setTimeout(() => {
|
||||||
|
processingRef.current = false;
|
||||||
|
decryptCurrentLesson();
|
||||||
|
}, 5000);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decryptedContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the lessons array with decrypted content
|
||||||
|
const updatedLessons = lessons.map(lesson =>
|
||||||
|
lesson.id === currentLesson.id
|
||||||
|
? { ...lesson, content: decryptedContent }
|
||||||
|
: lesson
|
||||||
|
);
|
||||||
|
|
||||||
|
setLessons(updatedLessons);
|
||||||
|
|
||||||
|
// Mark this lesson as decrypted
|
||||||
|
setDecryptedLessonIds(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentLesson.id]: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reset retry counter on success
|
||||||
|
retryCountRef.current[currentLesson.id] = 0;
|
||||||
|
} catch (error) {
|
||||||
|
// Silent error handling to prevent UI disruption
|
||||||
|
console.error('Decryption error:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
processingRef.current = false;
|
||||||
|
}
|
||||||
|
}, [currentLesson, hasAccess, paidCourse, decryptContent, lessons, setLessons, decryptedLessonIds]);
|
||||||
|
|
||||||
|
// Run decryption when lesson changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentLessonId) return;
|
||||||
|
|
||||||
|
// Always attempt decryption when activeIndex changes
|
||||||
|
if (hasAccess && paidCourse && !decryptedLessonIds[currentLessonId]) {
|
||||||
|
decryptCurrentLesson();
|
||||||
|
}
|
||||||
|
}, [currentLessonId, hasAccess, paidCourse, decryptedLessonIds, decryptCurrentLesson, activeIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (decryptTimeoutRef.current) {
|
||||||
|
clearTimeout(decryptTimeoutRef.current);
|
||||||
|
decryptTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (retryTimeoutRef.current) {
|
||||||
|
clearTimeout(retryTimeoutRef.current);
|
||||||
|
retryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
decryptionPerformed: isCurrentLessonDecrypted,
|
||||||
|
loading,
|
||||||
|
decryptedLessonIds
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useCourseDecryption;
|
@ -31,7 +31,9 @@ export default function MyApp({ Component, pageProps: { session, ...pageProps }
|
|||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
<Analytics />
|
<Analytics />
|
||||||
</main>
|
</main>
|
||||||
<BottomBar />
|
<div className="mt-12 min-bottom-bar:mt-0">
|
||||||
|
<BottomBar />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
@ -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';
|
||||||
|
@ -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}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user