Merge 4a1675b91a46cdcbce63aba2e981f8be79b649ff into 24e8a51877d67201879a67337712d40a769c0943

This commit is contained in:
Austin Kelsay 2025-04-14 18:23:37 +00:00 committed by GitHub
commit efa0d31a63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 5070 additions and 5708 deletions

9503
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -21,12 +21,11 @@
"@nostr-dev-kit/ndk": "^2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "^2.5.1",
"@octokit/plugin-retry": "^5.0.0",
"@octokit/plugin-throttling": "^6.0.0",
"@octokit/rest": "^19.0.7",
"@octokit/plugin-throttling": "^10.0.0",
"@octokit/rest": "^21.1.1",
"@prisma/client": "^5.17.0",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.51.21",
"@uiw/react-markdown-preview": "^5.1.2",
"@uiw/react-md-editor": "^3.11.0",
"@upstash/ratelimit": "^2.0.3",
"@vercel/analytics": "^1.3.1",
@ -38,9 +37,10 @@
"clsx": "^2.1.1",
"cors": "^2.8.5",
"discord.js": "^14.15.3",
"github-markdown-css": "^5.8.1",
"light-bolt11-decoder": "^3.1.1",
"lucide-react": "^0.441.0",
"next": "14.2.5",
"next": "^14.2.28",
"next-auth": "^4.24.7",
"next-remove-imports": "^1.0.12",
"nodemailer": "^6.9.15",
@ -50,6 +50,7 @@
"react": "^18",
"react-dom": "^18",
"reactflow": "^11.11.4",
"remark-breaks": "^4.0.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"uuid": "^10.0.0",

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState } from 'react';
import useWindowWidth from '@/hooks/useWindowWidth';
import Image from 'next/image';
import { getSession, signIn, useSession } from 'next-auth/react';

View File

@ -1,5 +1,4 @@
import React, { useState, useCallback } from 'react';
import { Tooltip } from 'primereact/tooltip';
import useWindowWidth from '@/hooks/useWindowWidth';
import MoreInfo from '@/components/MoreInfo';

View File

@ -1,7 +1,5 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useFetchGithubCommits } from '@/hooks/githubQueries/useFetchGithubCommits';
import { Tooltip } from 'primereact/tooltip';
import { formatDateTime } from '@/utils/time';
import useWindowWidth from '@/hooks/useWindowWidth';
import MoreInfo from '@/components/MoreInfo';

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, use } from 'react';
import React, { useState, useEffect } from 'react';
import { Carousel } from 'primereact/carousel';
import { parseCourseEvent } from '@/utils/nostr';
import { CourseTemplate } from '@/components/content/carousels/templates/CourseTemplate';

View File

@ -6,7 +6,6 @@ import { DocumentTemplate } from '@/components/content/carousels/templates/Docum
import TemplateSkeleton from '@/components/content/carousels/skeletons/TemplateSkeleton';
import { useDocuments } from '@/hooks/nostr/useDocuments';
import useWindowWidth from '@/hooks/useWindowWidth';
import { nip19 } from 'nostr-tools';
import { Divider } from 'primereact/divider';
const responsiveOptions = [
{

View File

@ -12,11 +12,9 @@ import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscripti
import { getTotalFromZaps } from '@/utils/lightning';
import { useSession } from 'next-auth/react';
import useWindowWidth from '@/hooks/useWindowWidth';
import dynamic from 'next/dynamic';
import { Toast } from 'primereact/toast';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), { ssr: false });
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const CombinedDetails = ({
processedEvent,
@ -203,7 +201,7 @@ const CombinedDetails = ({
const renderContent = () => {
if (decryptedContent) {
return <MDDisplay className="p-2 rounded-lg w-full" source={decryptedContent} />;
return <MarkdownDisplay content={decryptedContent} className="p-2 rounded-lg w-full" />;
}
if (paidResource && !decryptedContent) {
@ -231,7 +229,7 @@ const CombinedDetails = ({
}
if (processedEvent?.content) {
return <MDDisplay className="p-4 rounded-lg w-full" source={processedEvent.content} />;
return <MarkdownDisplay content={processedEvent.content} className="p-4 rounded-lg w-full" />;
}
return null;

View File

@ -7,18 +7,13 @@ import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
import { nip19 } from 'nostr-tools';
import { Divider } from 'primereact/divider';
import { getTotalFromZaps } from '@/utils/lightning';
import dynamic from 'next/dynamic';
import useWindowWidth from '@/hooks/useWindowWidth';
import appConfig from '@/config/appConfig';
import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson';
import { Menu } from 'primereact/menu';
import { Toast } from 'primereact/toast';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
import { useSession } from 'next-auth/react';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
const [zapAmount, setZapAmount] = useState(0);
@ -175,7 +170,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
if (isPaid && decryptionPerformed) {
return (
<div ref={mdDisplayRef}>
<MDDisplay className={'p-4 rounded-lg w-full'} source={lesson.content} />
<MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />
</div>
);
}
@ -217,7 +212,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
if (lesson?.content) {
return (
<div ref={mdDisplayRef}>
<MDDisplay className={'p-4 rounded-lg w-full'} source={lesson.content} />
<MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />
</div>
);
}

View File

@ -1,11 +1,8 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import axios from 'axios';
import { useToast } from '@/hooks/useToast';
import { Tag } from 'primereact/tag';
import Image from 'next/image';
import { useRouter } from 'next/router';
import CoursePaymentButton from '@/components/bitcoinConnect/CoursePaymentButton';
import ZapDisplay from '@/components/zaps/ZapDisplay';
import GenericButton from '@/components/buttons/GenericButton';
import { nip19 } from 'nostr-tools';
import { useImageProxy } from '@/hooks/useImageProxy';
@ -20,7 +17,10 @@ import useTrackCourse from '@/hooks/tracking/useTrackCourse';
import WelcomeModal from '@/components/onboarding/WelcomeModal';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Toast } from 'primereact/toast';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
// Import the desktop and mobile components
import DesktopCourseDetails from './DesktopCourseDetails';
import MobileCourseDetails from './MobileCourseDetails';
export default function CourseDetails({
processedEvent,
@ -29,6 +29,8 @@ export default function CourseDetails({
decryptionPerformed,
handlePaymentSuccess,
handlePaymentError,
isMobileView,
showCompletedTag = true,
}) {
const [zapAmount, setZapAmount] = useState(0);
const [author, setAuthor] = useState(null);
@ -39,7 +41,8 @@ export default function CourseDetails({
const { data: session, status } = useSession();
const { showToast } = useToast();
const windowWidth = useWindowWidth();
const isMobileView = windowWidth <= 768;
const localIsMobileView = windowWidth <= 768; // Use as fallback
const isPhone = isMobileView || localIsMobileView;
const { ndk } = useNDKContext();
const menuRef = useRef(null);
const toastRef = useRef(null);
@ -197,77 +200,33 @@ export default function CourseDetails({
);
}
// Shared props for both mobile and desktop components
const detailsProps = {
processedEvent,
paidCourse,
lessons,
decryptionPerformed,
author,
zapAmount,
zapsLoading,
menuItems,
returnImageProxy,
renderPaymentMessage,
isCompleted,
showCompletedTag
};
return (
<div className="w-full">
<div className="w-full bg-gray-800 p-4 rounded-lg">
<Toast ref={toastRef} />
<WelcomeModal />
<div className="relative w-full h-[400px] mb-8">
<Image
alt="course image"
src={returnImageProxy(processedEvent.image)}
fill
className="object-cover rounded-b-lg"
/>
<div className="absolute inset-0 bg-black bg-opacity-20"></div>
</div>
<div className="w-full mx-auto px-4 py-8 -mt-32 relative z-10 max-mob:px-0 max-tab:px-0">
<i
className={`pi pi-arrow-left cursor-pointer hover:opacity-75 absolute top-0 left-4`}
onClick={() => router.push('/')}
/>
<div className="mb-8 bg-gray-800/70 rounded-lg p-4 max-mob:rounded-t-none max-tab:rounded-t-none">
{isCompleted && <Tag severity="success" value="Completed" />}
<div className="flex flex-row items-center justify-between w-full">
<h1 className="text-4xl font-bold text-white">{processedEvent.name}</h1>
<ZapDisplay
zapAmount={zapAmount}
event={processedEvent}
zapsLoading={zapsLoading && zapAmount === 0}
/>
</div>
<div className="flex flex-wrap gap-2 mt-2 mb-4">
{processedEvent.topics &&
processedEvent.topics.length > 0 &&
processedEvent.topics.map((topic, index) => (
<Tag className="text-white" key={index} value={topic}></Tag>
))}
</div>
<div className="text-xl text-gray-200 mb-4 mt-4 max-mob:text-base">
{processedEvent.description &&
processedEvent.description
.split('\n')
.map((line, index) => <p key={index}>{line}</p>)}
</div>
<div className="flex items-center justify-between mt-8">
<div className="flex items-center">
<Image
alt="avatar image"
src={returnImageProxy(author?.avatar, author?.pubkey)}
width={50}
height={50}
className="rounded-full mr-4"
/>
<p className="text-lg text-white">
By{' '}
<a
rel="noreferrer noopener"
target="_blank"
className="text-blue-300 hover:underline"
>
{author?.username || author?.name || author?.pubkey}
</a>
</p>
</div>
<div className="flex justify-end">
<MoreOptionsMenu
menuItems={menuItems}
additionalLinks={processedEvent?.additionalLinks || []}
isMobileView={isMobileView}
/>
</div>
</div>
<div className="w-full mt-4">{renderPaymentMessage()}</div>
</div>
<div className="flex flex-col">
{isPhone ? (
<MobileCourseDetails {...detailsProps} />
) : (
<DesktopCourseDetails {...detailsProps} />
)}
</div>
</div>
);

View File

@ -0,0 +1,82 @@
import React from 'react';
import { useRouter } from 'next/router';
import GenericButton from '@/components/buttons/GenericButton';
import 'primeicons/primeicons.css';
const CourseHeader = ({
course,
isMobileView,
navbarHeight,
isNavbarMode = false
}) => {
const router = useRouter();
// Handle back button navigation
const handleBackNavigation = () => {
const { active, slug } = router.query;
// If we're on a specific lesson (has active param), remove it and stay on course page
if (active !== undefined) {
router.push(`/course/${slug}`, undefined, { shallow: true });
} else {
// If we're on the main course page (no active param), go back to previous page
router.push('/');
}
};
if (!course) return null;
// Navbar mode - compact version for the top navbar
if (isNavbarMode) {
return (
<div
className="flex items-center cursor-pointer hover:opacity-90"
onClick={() => router.push(`/course/${router.query.slug}`)}
>
<GenericButton
icon="pi pi-arrow-left"
onClick={(e) => {
e.stopPropagation();
router.push('/');
}}
className="mr-4 p-button-rounded p-button-text text-gray-300 hover:text-white"
rounded={true}
text={true}
aria-label="Go back to home"
/>
<div className="flex items-center pb-1">
<h2 className={`text-white font-semibold truncate ${isMobileView ? 'text-base max-w-[160px]' : 'text-lg max-w-[300px]'}`}>
{course.name}
</h2>
</div>
</div>
);
}
// Standard mode - for course page content
return (
<div
className="bg-transparent backdrop-blur-sm mb-0 p-3 px-4 sticky z-20 flex items-center"
style={{ top: `${navbarHeight}px` }}
>
<div className="flex items-center max-w-[90%]">
<GenericButton
icon="pi pi-arrow-left"
onClick={handleBackNavigation}
className="mr-3 p-button-rounded p-button-text text-gray-300 hover:text-white"
tooltip="Go back"
tooltipOptions={{ position: 'bottom' }}
rounded={true}
text={true}
aria-label="Go back"
/>
<h2 className={`font-medium text-gray-100 ${isMobileView ? 'text-sm' : 'text-base'} truncate`}>
{course.name}
</h2>
</div>
</div>
);
};
export default CourseHeader;

View File

@ -4,7 +4,6 @@ import Image from 'next/image';
import { useImageProxy } from '@/hooks/useImageProxy';
import { getTotalFromZaps } from '@/utils/lightning';
import ZapDisplay from '@/components/zaps/ZapDisplay';
import dynamic from 'next/dynamic';
import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
import { Toast } from 'primereact/toast';
import useTrackDocumentLesson from '@/hooks/tracking/useTrackDocumentLesson';
@ -13,10 +12,7 @@ import { nip19 } from 'nostr-tools';
import appConfig from '@/config/appConfig';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
import { useSession } from 'next-auth/react';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
const [zapAmount, setZapAmount] = useState(0);
@ -114,7 +110,7 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete
const renderContent = () => {
if (isPaid && decryptionPerformed) {
return <MDDisplay className="p-4 rounded-lg w-full" source={lesson.content} />;
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />;
}
if (isPaid && !decryptionPerformed) {
return (
@ -124,7 +120,7 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete
);
}
if (lesson?.content) {
return <MDDisplay className="p-4 rounded-lg w-full" source={lesson.content} />;
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />;
}
return null;
};

View File

@ -0,0 +1,145 @@
import React from 'react';
import { Tag } from 'primereact/tag';
import Image from 'next/image';
import { useImageProxy } from '@/hooks/useImageProxy';
import GenericButton from '@/components/buttons/GenericButton';
const CourseSidebar = ({
lessons,
activeIndex,
onLessonSelect,
completedLessons,
isMobileView,
onClose,
sidebarVisible,
setSidebarVisible,
hideToggleButton = false,
}) => {
const { returnImageProxy } = useImageProxy();
const navbarHeight = 60; // Match the navbar height
const handleToggle = () => {
// Only use the parent's state setter
if (setSidebarVisible) {
setSidebarVisible(!sidebarVisible);
}
};
const LessonItem = ({ lesson, index }) => (
<li
className={`
rounded-lg overflow-hidden transition-all duration-200
${
activeIndex === index
? 'bg-blue-900/40 border-l-4 border-blue-500'
: 'hover:bg-gray-700/50 active:bg-gray-700/80 border-l-4 border-transparent'
}
${isMobileView ? 'mb-3' : 'mb-2'}
`}
onClick={() => onLessonSelect(index)}
>
<div className={`flex items-start p-3 cursor-pointer ${isMobileView ? 'p-4' : 'p-3'}`}>
{lesson.image && (
<div
className={`relative rounded-md overflow-hidden flex-shrink-0 mr-3 ${isMobileView ? 'w-16 h-16' : 'w-12 h-12'}`}
>
<Image
src={returnImageProxy(lesson.image)}
alt={`Lesson ${index + 1} thumbnail`}
fill
className="object-cover"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start w-full">
<span
className={`font-medium block mb-1 text-gray-300 ${isMobileView ? 'text-base' : 'text-sm'}`}
>
Lesson {index + 1}
</span>
{completedLessons.includes(lesson.id) && (
<Tag severity="success" value="Completed" className="ml-1 py-1 text-xs" />
)}
</div>
<h3
className={`font-medium leading-tight line-clamp-2 text-[#f8f8ff] ${isMobileView ? 'text-base' : ''}`}
>
{lesson.title}
</h3>
</div>
</div>
</li>
);
// Sidebar content component for reuse
const SidebarContent = () => (
<div className="flex flex-col h-full bg-gray-800 text-[#f8f8ff] pl-3 py-4">
<div className="flex items-center justify-between border-b border-gray-700 pb-4 mb-4">
<h2 className="font-bold text-white text-lg">Course Lessons</h2>
{sidebarVisible && !hideToggleButton && !isMobileView && (
<GenericButton
icon="pi pi-times"
onClick={handleToggle}
className="p-button-rounded p-button-text text-gray-300 hover:text-white p-button-sm"
tooltip="Close sidebar"
tooltipOptions={{ position: 'left' }}
rounded={true}
/>
)}
</div>
<div className="overflow-y-auto flex-1 pr-2">
<ul className="space-y-2">
{lessons.map((lesson, index) => (
<LessonItem key={index} lesson={lesson} index={index} />
))}
</ul>
</div>
</div>
);
// Mobile content tab
const MobileLessonsTab = () => (
<div className="bg-gray-800 rounded-lg overflow-hidden border border-gray-800 shadow-md mb-6">
<div className="bg-gray-800 p-4 border-b border-gray-700">
<h2 className="font-bold text-white text-xl">Course Lessons</h2>
</div>
<div className="p-4">
<ul className="space-y-3">
{lessons.map((lesson, index) => (
<LessonItem key={index} lesson={lesson} index={index} />
))}
</ul>
</div>
</div>
);
return (
<>
{/* Mobile view - direct content instead of sidebar */}
{isMobileView && sidebarVisible && (
<MobileLessonsTab />
)}
{/* Desktop sidebar */}
{!isMobileView && (
<div className="relative flex flex-row-reverse z-[999]">
<div
className={`transition-all duration-500 ease-in-out flex ${
sidebarVisible ? 'w-80 opacity-100' : 'w-0 opacity-0 overflow-hidden'
}`}
>
<div className="ml-2 w-80 h-[calc(100vh-400px)] sticky overflow-hidden rounded-lg border border-gray-800 shadow-md bg-gray-800"
style={{ top: `${navbarHeight + 70}px` }}> {/* Adjusted to match new header spacing */}
<div className="h-full overflow-y-auto">
<SidebarContent />
</div>
</div>
</div>
</div>
)}
</>
);
};
export default CourseSidebar;

View File

@ -0,0 +1,151 @@
import React from 'react';
import Image from 'next/image';
import { Tag } from 'primereact/tag';
import { useRouter } from 'next/router';
import ZapDisplay from '@/components/zaps/ZapDisplay';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
import { Divider } from 'primereact/divider';
export default function DesktopCourseDetails({
processedEvent,
paidCourse,
lessons,
decryptionPerformed,
author,
zapAmount,
zapsLoading,
menuItems,
returnImageProxy,
renderPaymentMessage,
isCompleted,
showCompletedTag
}) {
const router = useRouter();
return (
<>
{/* Header with course image, title and options */}
<div className="flex mb-6">
{/* Course image */}
<div className="relative w-52 h-32 mr-6 flex-shrink-0 rounded-lg overflow-hidden">
<Image
alt="course image"
src={returnImageProxy(processedEvent.image)}
fill
className="object-cover"
/>
</div>
{/* Title and options */}
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
{isCompleted && showCompletedTag && (
<Tag severity="success" value="Completed" className="mb-2" />
)}
<h1 className="text-2xl font-bold text-white">{processedEvent.name}</h1>
</div>
<div className="flex items-center space-x-2">
<ZapDisplay
zapAmount={zapAmount}
event={processedEvent}
zapsLoading={zapsLoading && zapAmount === 0}
/>
<MoreOptionsMenu
menuItems={menuItems}
additionalLinks={processedEvent?.additionalLinks || []}
isMobileView={false}
/>
</div>
</div>
{/* Topics/tags */}
<div className="flex flex-wrap gap-2 mb-3">
{processedEvent.topics &&
processedEvent.topics.length > 0 &&
processedEvent.topics.map((topic, index) => (
<Tag className="text-white" key={index} value={topic}></Tag>
))}
</div>
{/* Author info */}
<div className="flex items-center">
<Image
alt="avatar image"
src={returnImageProxy(author?.avatar, author?.pubkey)}
width={32}
height={32}
className="rounded-full mr-2"
/>
<p className="text-gray-300">
Created by{' '}
<a
rel="noreferrer noopener"
target="_blank"
className="text-blue-300 hover:underline"
>
{author?.username || author?.name || author?.pubkey}
</a>
</p>
</div>
</div>
</div>
<Divider className="my-4" />
{/* Course details */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left column: Description */}
<div className="lg:col-span-2">
<h2 className="text-xl font-semibold mb-3 text-white">About This Course</h2>
<div className="text-gray-300 mb-4">
{processedEvent.description &&
processedEvent.description
.split('\n')
.map((line, index) => <p key={index} className="mb-2">{line}</p>)}
</div>
{/* Payment section */}
<div className="mt-4">
{renderPaymentMessage()}
</div>
</div>
{/* Right column: Course details */}
<div className="bg-gray-800 rounded-lg h-fit p-4">
<h2 className="text-xl font-semibold mb-3 text-white">Course Information</h2>
<div className="space-y-4">
<div>
<h3 className="text-gray-300 font-medium mb-2">Course Content</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-400">Lessons</p>
<p className="font-semibold text-white">{lessons.length}</p>
</div>
{paidCourse && (
<div>
<p className="text-sm text-gray-400">Price</p>
<p className="font-semibold text-white">{processedEvent.price} sats</p>
</div>
)}
</div>
</div>
{processedEvent.published && (
<div>
<h3 className="text-gray-300 font-medium mb-2">Details</h3>
<div>
<p className="text-sm text-gray-400">Published</p>
<p className="font-semibold text-white">
{new Date(processedEvent.published * 1000).toLocaleDateString()}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</>
);
}

View File

@ -7,17 +7,13 @@ import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
import { nip19 } from 'nostr-tools';
import { Divider } from 'primereact/divider';
import { getTotalFromZaps } from '@/utils/lightning';
import dynamic from 'next/dynamic';
import useWindowWidth from '@/hooks/useWindowWidth';
import appConfig from '@/config/appConfig';
import useTrackDocumentLesson from '@/hooks/tracking/useTrackDocumentLesson';
import { Toast } from 'primereact/toast';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
import { useSession } from 'next-auth/react';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
const [zapAmount, setZapAmount] = useState(0);
@ -118,7 +114,7 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
const renderContent = () => {
if (isPaid && decryptionPerformed) {
return <MDDisplay className="p-4 rounded-lg w-full" source={lesson.content} />;
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />;
}
if (isPaid && !decryptionPerformed) {
return (
@ -133,7 +129,7 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
);
}
if (lesson?.content) {
return <MDDisplay className="p-4 rounded-lg w-full" source={lesson.content} />;
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />;
}
return null;
};

View File

@ -4,7 +4,6 @@ import { useImageProxy } from '@/hooks/useImageProxy';
import { Tag } from 'primereact/tag';
import GenericButton from '@/components/buttons/GenericButton';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import axios from 'axios';
import { nip04, nip19 } from 'nostr-tools';
import { v4 as uuidv4 } from 'uuid';
@ -18,10 +17,7 @@ import { validateEvent } from '@/utils/nostr';
import appConfig from '@/config/appConfig';
import { useEncryptContent } from '@/hooks/encryption/useEncryptContent';
import 'primeicons/primeicons.css';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
export default function DraftCourseDetails({ processedEvent, draftId, lessons }) {
const [author, setAuthor] = useState(null);
@ -467,7 +463,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
</div>
<div className="w-[75vw] mx-auto mt-12 p-12 border-t-2 border-gray-300 max-tab:p-0 max-mob:p-0 max-tab:max-w-[100vw] max-mob:max-w-[100vw]">
{processedEvent?.content && (
<MDDisplay className="p-4 rounded-lg" source={processedEvent.content} />
<MarkdownDisplay content={processedEvent.content} className="p-4 rounded-lg" />
)}
</div>
</div>

View File

@ -6,11 +6,7 @@ import Image from 'next/image';
import { useImageProxy } from '@/hooks/useImageProxy';
import { formatDateTime, formatUnixTimestamp } from '@/utils/time';
import { useRouter } from 'next/router';
import dynamic from 'next/dynamic';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const DraftCourseLesson = ({ lesson, course }) => {
const [isPublished, setIsPublished] = useState(false);
@ -149,7 +145,7 @@ const DraftCourseLesson = ({ lesson, course }) => {
</div>
</div>
<div className="w-[75vw] mx-auto mt-12 p-12 border-t-2 border-gray-300 max-tab:p-0 max-mob:p-0 max-tab:max-w-[100vw] max-mob:max-w-[100vw]">
{lesson?.content && <MDDisplay className="p-4 rounded-lg" source={lesson.content} />}
{lesson?.content && <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg" />}
</div>
</div>
);

View File

@ -0,0 +1,135 @@
import React from 'react';
import Image from 'next/image';
import { Tag } from 'primereact/tag';
import { useRouter } from 'next/router';
import ZapDisplay from '@/components/zaps/ZapDisplay';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
import { Divider } from 'primereact/divider';
export default function MobileCourseDetails({
processedEvent,
paidCourse,
lessons,
decryptionPerformed,
author,
zapAmount,
zapsLoading,
menuItems,
returnImageProxy,
renderPaymentMessage,
isCompleted,
showCompletedTag
}) {
const router = useRouter();
return (
<>
{/* Mobile-specific layout */}
<div className="mb-4">
{/* Topics/tags right below image (image is in parent component) */}
<div className="flex flex-wrap gap-2 mb-3 mt-2">
{processedEvent.topics &&
processedEvent.topics.length > 0 &&
processedEvent.topics.map((topic, index) => (
<Tag className="text-white" key={index} value={topic}></Tag>
))}
</div>
{/* Title and zaps in same row */}
<div className="flex justify-between items-center mb-3">
<h1 className="text-xl font-bold text-white mr-3">{processedEvent.name}</h1>
<ZapDisplay
zapAmount={zapAmount}
event={processedEvent}
zapsLoading={zapsLoading && zapAmount === 0}
/>
</div>
{/* Author info and more options in bottom row */}
<div className="flex justify-between items-center mb-2">
<div className="flex items-center">
<Image
alt="avatar image"
src={returnImageProxy(author?.avatar, author?.pubkey)}
width={32}
height={32}
className="rounded-full mr-2"
/>
<p className="text-gray-300 text-sm">
Created by{' '}
<a
rel="noreferrer noopener"
target="_blank"
href={`/profile/${author?.pubkey}`}
className="text-blue-300 hover:underline"
>
{author?.username || author?.name || author?.pubkey}
</a>
</p>
</div>
<MoreOptionsMenu
menuItems={menuItems}
additionalLinks={processedEvent?.additionalLinks || []}
isMobileView={true}
/>
</div>
</div>
<Divider className="my-4" />
{/* Course details */}
<div className="grid grid-cols-1 gap-4">
{/* Description */}
<div>
<h2 className="text-xl font-semibold mb-3 text-white">About This Course</h2>
<div className="text-gray-300 mb-4">
{processedEvent.description &&
processedEvent.description
.split('\n')
.map((line, index) => <p key={index} className="text-sm mb-2">{line}</p>)}
</div>
{/* Payment section */}
<div className="mt-4">
{renderPaymentMessage()}
</div>
</div>
{/* Course details */}
<div className="bg-gray-800 rounded-lg h-fit p-3 pl-0">
<h2 className="text-lg font-semibold mb-3 text-white">Course Information</h2>
<div className="space-y-4">
<div>
<h3 className="text-gray-300 font-medium mb-2">Course Content</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-400">Lessons</p>
<p className="font-semibold text-white">{lessons.length}</p>
</div>
{paidCourse && (
<div>
<p className="text-sm text-gray-400">Price</p>
<p className="font-semibold text-white">{processedEvent.price} sats</p>
</div>
)}
</div>
</div>
{processedEvent.published && (
<div>
<h3 className="text-gray-300 font-medium mb-2">Details</h3>
<div>
<p className="text-sm text-gray-400">Published</p>
<p className="font-semibold text-white">
{new Date(processedEvent.published * 1000).toLocaleDateString()}
</p>
</div>
</div>
)}
</div>
</div>
</div>
</>
);
}

View File

@ -6,7 +6,6 @@ import { useImageProxy } from '@/hooks/useImageProxy';
import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
import { nip19 } from 'nostr-tools';
import { getTotalFromZaps } from '@/utils/lightning';
import dynamic from 'next/dynamic';
import { Divider } from 'primereact/divider';
import appConfig from '@/config/appConfig';
import useWindowWidth from '@/hooks/useWindowWidth';
@ -14,10 +13,7 @@ import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson';
import { Toast } from 'primereact/toast';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
import { useSession } from 'next-auth/react';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
const [zapAmount, setZapAmount] = useState(0);
@ -170,7 +166,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
if (isPaid && decryptionPerformed) {
return (
<div ref={mdDisplayRef}>
<MDDisplay className="p-0 rounded-lg w-full" source={lesson.content} />
<MarkdownDisplay content={lesson.content} className="p-0 rounded-lg w-full" />
</div>
);
} else if (isPaid && !decryptionPerformed) {
@ -196,7 +192,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
} else if (lesson?.content) {
return (
<div ref={mdDisplayRef}>
<MDDisplay className="p-0 rounded-lg w-full" source={lesson.content} />
<MarkdownDisplay content={lesson.content} className="p-0 rounded-lg w-full" />
</div>
);
}

View File

@ -12,13 +12,9 @@ import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscripti
import { getTotalFromZaps } from '@/utils/lightning';
import { useSession } from 'next-auth/react';
import useWindowWidth from '@/hooks/useWindowWidth';
import dynamic from 'next/dynamic';
import { Toast } from 'primereact/toast';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const DocumentDetails = ({
processedEvent,
@ -211,7 +207,7 @@ const DocumentDetails = ({
const renderContent = () => {
if (decryptedContent) {
return <MDDisplay className="p-2 rounded-lg w-full" source={decryptedContent} />;
return <MarkdownDisplay content={decryptedContent} className="p-2 rounded-lg w-full" />;
}
if (paidResource && !decryptedContent) {
return (
@ -237,7 +233,7 @@ const DocumentDetails = ({
);
}
if (processedEvent?.content) {
return <MDDisplay className="p-4 rounded-lg w-full" source={processedEvent.content} />;
return <MarkdownDisplay content={processedEvent.content} className="p-4 rounded-lg w-full" />;
}
return null;
};

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import CommunityMessage from '@/components/feeds/messages/CommunityMessage';
import { parseMessageEvent } from '@/utils/nostr';
import { ProgressSpinner } from 'primereact/progressspinner';
import { useNDKContext } from '@/context/NDKContext';

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import Image from 'next/image';
import GenericButton from '@/components/buttons/GenericButton';
import { useImageProxy } from '@/hooks/useImageProxy';

View File

@ -12,13 +12,9 @@ import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscripti
import { getTotalFromZaps } from '@/utils/lightning';
import { useSession } from 'next-auth/react';
import useWindowWidth from '@/hooks/useWindowWidth';
import dynamic from 'next/dynamic';
import { Toast } from 'primereact/toast';
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const VideoDetails = ({
processedEvent,
@ -208,7 +204,7 @@ const VideoDetails = ({
const renderContent = () => {
if (decryptedContent) {
return <MDDisplay className="p-0 rounded-lg w-full" source={decryptedContent} />;
return <MarkdownDisplay content={decryptedContent} className="p-0 rounded-lg w-full" />;
}
if (paidResource && !decryptedContent) {
return (
@ -241,7 +237,7 @@ const VideoDetails = ({
);
}
if (processedEvent?.content) {
return <MDDisplay className="p-0 rounded-lg w-full" source={processedEvent.content} />;
return <MarkdownDisplay content={processedEvent.content} className="p-0 rounded-lg w-full" />;
}
return null;
};

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useRef } from 'react';
import { InputTextarea } from 'primereact/inputtextarea';
import GenericButton from '@/components/buttons/GenericButton';
import { useNDKContext } from '@/context/NDKContext';

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import { ProgressSpinner } from 'primereact/progressspinner';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import { InputText } from 'primereact/inputtext';
import { InputNumber } from 'primereact/inputnumber';
import { InputSwitch } from 'primereact/inputswitch';

View File

@ -0,0 +1,27 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
import remarkGfm from 'remark-gfm';
import remarkBreaks from 'remark-breaks';
import 'github-markdown-css/github-markdown-dark.css';
const MarkdownDisplay = ({ content, className = "" }) => {
if (!content) return null;
return (
<div className={`markdown-body bg-gray-900 ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw]}
components={{
a: ({node, ...props}) => <a target="_blank" rel="noopener noreferrer" {...props} />,
img: ({node, ...props}) => <img {...props} className="max-w-full rounded my-2" />
}}
>
{content}
</ReactMarkdown>
</div>
);
};
export default MarkdownDisplay;

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React from 'react';
import { TabMenu } from 'primereact/tabmenu';
import GenericButton from '@/components/buttons/GenericButton';
import Image from 'next/image';

View File

@ -1,9 +1,10 @@
import React from 'react';
import { TabMenu } from 'primereact/tabmenu';
import GenericButton from '@/components/buttons/GenericButton';
export default function MenuTab({ items, activeIndex, onTabChange }) {
export default function MenuTab({ items, activeIndex, onTabChange, sidebarVisible, onToggleSidebar, isMobileView = false }) {
return (
<div className="w-[100%]">
<div className="w-[100%] relative">
<TabMenu
className="w-full bg-transparent border-none"
model={items}
@ -20,6 +21,24 @@ export default function MenuTab({ items, activeIndex, onTabChange }) {
},
}}
/>
{/* Sidebar toggle button positioned at the far right - hidden on mobile */}
{!isMobileView && (
<div className="absolute right-2 top-0 flex items-center h-full">
<GenericButton
icon={sidebarVisible
? "pi pi-times"
: "pi pi-chevron-left"}
onClick={onToggleSidebar}
outlined={true}
rounded={true}
size="small"
tooltip={sidebarVisible ? "Hide lessons" : "Show lessons"}
tooltipOptions={{ position: 'bottom' }}
aria-label="Toggle course lessons"
/>
</div>
)}
</div>
);
}

View File

@ -8,6 +8,10 @@ import { useSession } from 'next-auth/react';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import useWindowWidth from '@/hooks/useWindowWidth';
import CourseHeader from '../content/courses/CourseHeader';
import { useNDKContext } from '@/context/NDKContext';
import { nip19 } from 'nostr-tools';
import { parseCourseEvent } from '@/utils/nostr';
const Navbar = () => {
const router = useRouter();
@ -17,6 +21,51 @@ const Navbar = () => {
const [isHovered, setIsHovered] = useState(false);
const [showMobileSearch, setShowMobileSearch] = useState(false);
const menu = useRef(null);
const { ndk } = useNDKContext();
const [course, setCourse] = useState(null);
const [isCompleted, setIsCompleted] = useState(false);
// Check if we're on a course page
const isCoursePage = router.pathname.startsWith('/course/');
// Fetch course data when on a course page
useEffect(() => {
if (isCoursePage && router.isReady && ndk) {
const fetchCourse = async () => {
try {
const { slug } = router.query;
let identifier;
if (slug.includes('naddr')) {
const { data } = nip19.decode(slug);
identifier = data?.identifier;
} else {
identifier = slug;
}
if (identifier) {
const event = await ndk.fetchEvent({ '#d': [identifier] });
if (event) {
const parsedCourse = parseCourseEvent(event);
setCourse(parsedCourse);
// Check if course is completed (simplified for nav display)
if (session?.user?.completedCourses?.includes(identifier)) {
setIsCompleted(true);
}
}
}
} catch (error) {
console.error('Error fetching course for navbar:', error);
}
};
fetchCourse();
} else {
setCourse(null);
setIsCompleted(false);
}
}, [isCoursePage, router.isReady, router.query, ndk, session?.user?.completedCourses]);
// Lock/unlock body scroll when mobile search is shown/hidden
useEffect(() => {
@ -65,21 +114,33 @@ const Navbar = () => {
>
{/* Left section */}
<div className="flex items-center flex-1">
<div
onClick={() => router.push('/')}
className="flex flex-row items-center justify-center cursor-pointer hover:opacity-80"
>
<Image
alt="logo"
src="/images/plebdevs-icon.png"
width={50}
height={50}
className="rounded-full max-tab:hidden max-mob:hidden"
{isCoursePage && course ? (
/* Course header in navbar mode */
<CourseHeader
course={course}
isMobileView={windowWidth <= 600}
isCompleted={isCompleted}
isNavbarMode={true}
/>
<h1 className="text-white text-xl font-semibold max-tab:text-2xl max-mob:text-2xl pb-1 pl-2">
PlebDevs
</h1>
</div>
) : (
/* Regular PlebDevs branding */
<div
onClick={() => router.push('/')}
className="flex flex-row items-center justify-center cursor-pointer hover:opacity-80"
>
<Image
alt="logo"
src="/images/plebdevs-icon.png"
width={50}
height={50}
className="rounded-full max-tab:hidden max-mob:hidden"
/>
<h1 className="text-white text-xl font-semibold max-tab:text-2xl max-mob:text-2xl pb-1 pl-2">
PlebDevs
</h1>
</div>
)}
{windowWidth > 600 ? (
<div
className={`ml-2 p-2 cursor-pointer transition-all duration-300 flex items-center justify-center ${isHovered ? 'bg-gray-700 rounded-full' : ''}`}

View File

@ -1,7 +1,6 @@
import React from 'react';
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import useWindowWidth from '@/hooks/useWindowWidth';
import ProgressListItem from '@/components/content/lists/ProgressListItem';
import { formatDateTime } from '@/utils/time';
import { ProgressSpinner } from 'primereact/progressspinner';

View File

@ -1,5 +1,4 @@
import React, { useEffect } from 'react';
import { Menu } from 'primereact/menu';
import GenericButton from '@/components/buttons/GenericButton';
import { signIn } from 'next-auth/react';
import Image from 'next/image';

View File

@ -1,7 +1,6 @@
import React, { useRef, useState } from 'react';
import Image from 'next/image';
import { Menu } from 'primereact/menu';
import { Tooltip } from 'primereact/tooltip';
import { Dialog } from 'primereact/dialog';
import { nip19 } from 'nostr-tools';
import { useImageProxy } from '@/hooks/useImageProxy';

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import useWindowWidth from '@/hooks/useWindowWidth';

View File

@ -1,4 +1,4 @@
import React, { useRef, useEffect, useState, useMemo } from 'react';
import React, { useRef, useState, useMemo } from 'react';
import { OverlayPanel } from 'primereact/overlaypanel';
import ZapForm from './ZapForm';
import { ProgressSpinner } from 'primereact/progressspinner';

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import { nip19 } from 'nostr-tools';
import appConfig from '@/config/appConfig';

View File

@ -11,7 +11,7 @@ const appConfig = {
],
authorPubkeys: [
'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741',
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345',
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345'
],
customLightningAddresses: [
{

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useEffect, useState, useMemo } from 'react';
import React, { createContext, useContext, useEffect, useState } from 'react';
import NDK, { NDKNip07Signer } from '@nostr-dev-kit/ndk';
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
import { useLocalStorage } from '@/hooks/useLocalStorage';

View File

@ -10,7 +10,6 @@ import '@/styles/custom-theme.css'; // custom theme
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';
import { useRouter } from 'next/router';
import { NDKProvider } from '@/context/NDKContext';
import { Analytics } from '@vercel/analytics/react';

View File

@ -1,22 +1,24 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/router';
import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr';
import CourseDetails from '@/components/content/courses/CourseDetails';
import VideoLesson from '@/components/content/courses/VideoLesson';
import DocumentLesson from '@/components/content/courses/DocumentLesson';
import CombinedLesson from '@/components/content/courses/CombinedLesson';
import CourseSidebar from '@/components/content/courses/CourseSidebar';
import CourseHeader from '@/components/content/courses/CourseHeader';
import { useNDKContext } from '@/context/NDKContext';
import { useSession } from 'next-auth/react';
import axios from 'axios';
import { nip04, nip19 } from 'nostr-tools';
import { nip19 } from 'nostr-tools';
import { useToast } from '@/hooks/useToast';
import { ProgressSpinner } from 'primereact/progressspinner';
import { Accordion, AccordionTab } from 'primereact/accordion';
import { Tag } from 'primereact/tag';
import { useDecryptContent } from '@/hooks/encryption/useDecryptContent';
import dynamic from 'next/dynamic';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), { ssr: false });
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
import useWindowWidth from '@/hooks/useWindowWidth';
import MenuTab from '@/components/menutab/MenuTab';
import { Tag } from 'primereact/tag';
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
const useCourseData = (ndk, fetchAuthor, router) => {
const [course, setCourse] = useState(null);
@ -169,8 +171,43 @@ const Course = () => {
const { ndk, addSigner } = useNDKContext();
const { data: session, update } = useSession();
const { showToast } = useToast();
const [expandedIndex, setExpandedIndex] = useState(null);
const [activeIndex, setActiveIndex] = useState(0);
const [completedLessons, setCompletedLessons] = useState([]);
const [sidebarVisible, setSidebarVisible] = useState(false);
const [nAddress, setNAddress] = useState(null);
const windowWidth = useWindowWidth();
const isMobileView = windowWidth <= 968;
const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab
const navbarHeight = 60; // Match the height from Navbar component
useEffect(() => {
if (router.isReady) {
const { slug } = router.query;
if (slug.includes('naddr')) {
setNAddress(slug);
} else {
// todo: no naddress?
}
}
}, [router.isReady, router.query.slug]);
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]);
const setCompleted = useCallback(lessonId => {
setCompletedLessons(prev => [...prev, lessonId]);
@ -192,12 +229,14 @@ const Course = () => {
paidCourse,
loading: courseLoading,
} = useCourseData(ndk, fetchAuthor, router);
const { lessons, uniqueLessons, setLessons } = useLessons(
ndk,
fetchAuthor,
lessonIds,
course?.pubkey
);
const { decryptionPerformed, loading: decryptionLoading } = useDecryption(
session,
paidCourse,
@ -206,25 +245,22 @@ const Course = () => {
setLessons
);
useEffect(() => {
if (router.isReady) {
const { active } = router.query;
if (active !== undefined) {
setExpandedIndex(parseInt(active, 10));
} else {
setExpandedIndex(null);
}
}
}, [router.isReady, router.query]);
// Check if course is completed - moved after course is initialized
const isCourseCompleted = useMemo(() => {
if (!course || !completedLessons.length) return false;
// A course is completed if at least one lesson is completed
// You can change this logic if needed (e.g., all lessons must be completed)
return completedLessons.length > 0;
}, [completedLessons, course]);
const handleAccordionChange = e => {
const newIndex = e.index === expandedIndex ? null : e.index;
setExpandedIndex(newIndex);
const handleLessonSelect = index => {
setActiveIndex(index);
router.push(`/course/${router.query.slug}?active=${index}`, undefined, { shallow: true });
if (newIndex !== null) {
router.push(`/course/${router.query.slug}?active=${newIndex}`, undefined, { shallow: true });
} else {
router.push(`/course/${router.query.slug}`, undefined, { shallow: true });
// On mobile, switch to content tab after selection
if (isMobileView) {
setActiveTab('content');
setSidebarVisible(false);
}
};
@ -245,6 +281,156 @@ const Course = () => {
);
};
const toggleTab = (index) => {
const tabMap = ['overview', 'content', 'qa'];
// If mobile and we have the lessons tab, insert it at index 2
if (isMobileView) {
tabMap.splice(2, 0, 'lessons');
}
const tabName = tabMap[index];
setActiveTab(tabName);
// Only show/hide sidebar on mobile - desktop keeps sidebar visible
if (isMobileView) {
if (tabName === 'lessons') {
setSidebarVisible(true);
} else {
setSidebarVisible(false);
}
}
};
const handleToggleSidebar = () => {
setSidebarVisible(!sidebarVisible);
};
// Map active tab name back to index for MenuTab
const getActiveTabIndex = () => {
const tabMap = ['overview', 'content', 'qa'];
if (isMobileView) {
tabMap.splice(2, 0, 'lessons');
}
return tabMap.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) % getTabItems().length;
toggleTab(nextIndex);
} else if (e.key === 'ArrowLeft') {
const currentIndex = getActiveTabIndex();
const prevIndex = (currentIndex - 1 + getTabItems().length) % getTabItems().length;
toggleTab(prevIndex);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [activeTab]);
// Render the QA section (empty for now)
const renderQASection = () => {
return (
<div className="rounded-lg p-8 mt-4 bg-gray-800">
<h2 className="text-xl font-bold mb-4">Comments</h2>
{nAddress !== null ? (
<ZapThreadsWrapper
anchor={nAddress}
user={session?.user?.pubkey ? nip19.npubEncode(session?.user?.pubkey) : 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"
/>
) : course?.d ? (
<ZapThreadsWrapper
anchor={course.d}
user={session?.user?.pubkey ? nip19.npubEncode(session?.user?.pubkey) : 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"
/>
) : (
<p>Loading comments...</p>
)}
</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 (
<div className="w-full h-full flex items-center justify-center">
@ -263,6 +449,7 @@ const Course = () => {
isPaid={paidCourse}
setCompleted={setCompleted}
/>
);
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
return (
@ -289,50 +476,113 @@ const Course = () => {
return (
<>
{course && paidCourse !== null && (
<CourseDetails
processedEvent={course}
paidCourse={paidCourse}
lessons={uniqueLessons}
decryptionPerformed={decryptionPerformed}
handlePaymentSuccess={handlePaymentSuccess}
handlePaymentError={handlePaymentError}
/>
)}
<Accordion
activeIndex={expandedIndex}
onTabChange={handleAccordionChange}
className="mt-4 px-4 max-mob:px-0 max-tab:px-0"
>
{uniqueLessons.length > 0 &&
uniqueLessons.map((lesson, index) => (
<AccordionTab
key={index}
pt={{
root: { className: 'border-none' },
header: { className: 'border-none' },
headerAction: { className: 'border-none' },
content: { className: 'border-none max-mob:px-0 max-tab:px-0' },
accordiontab: { className: 'border-none' },
}}
header={
<div className="flex align-items-center justify-between w-full">
<span
id={`lesson-${index}`}
className="font-bold text-xl"
>{`Lesson ${index + 1}: ${lesson.title}`}</span>
{completedLessons.includes(lesson.id) ? (
<Tag severity="success" value="Completed" />
) : null}
<div className="mx-auto px-8 max-mob:px-0 mb-12 mt-4">
{/* Tab navigation using MenuTab component */}
<div className="sticky z-10 bg-transparent border-b border-gray-700/30"
style={{
top: `${navbarHeight}px`,
height: `${navbarHeight}px`
}}>
<MenuTab
items={getTabItems()}
activeIndex={getActiveTabIndex()}
onTabChange={(index) => toggleTab(index)}
sidebarVisible={sidebarVisible}
onToggleSidebar={handleToggleSidebar}
isMobileView={isMobileView}
/>
</div>
{/* Revised layout structure to prevent content flexing */}
<div className="relative mt-4">
{/* Main content area with fixed width */}
<div className={`transition-all duration-500 ease-in-out ${isMobileView ? 'w-full' : 'w-full'}`}
style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}>
{/* Overview tab content */}
<div className={`${activeTab === 'overview' ? 'block' : 'hidden'}`}>
{renderOverviewSection()}
</div>
{/* Content tab content */}
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
{renderLesson(uniqueLessons[activeIndex])}
</div>
}
) : (
<div className="text-center bg-gray-800 rounded-lg p-8">
<p>Select a lesson from the sidebar to begin learning.</p>
</div>
)}
{course?.content && (
<div className="mt-8 bg-gray-800 rounded-lg shadow-sm">
<MarkdownDisplay content={course.content} className="p-4 rounded-lg" />
</div>
)}
</div>
{/* QA tab content */}
<div className={`${activeTab === 'qa' ? 'block' : 'hidden'}`}>
{renderQASection()}
</div>
</div>
{/* Course Sidebar - positioned absolutely on desktop when visible */}
{!isMobileView ? (
<div
className={`transition-all duration-500 ease-in-out ${
sidebarVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full'
}`}
style={{
position: 'absolute',
top: '0',
right: '0',
width: '320px',
height: '100%',
zIndex: 999,
overflow: 'visible',
pointerEvents: sidebarVisible ? 'auto' : 'none'
}}
>
<div className="w-full py-4 rounded-b-lg">{renderLesson(lesson)}</div>
</AccordionTab>
))}
</Accordion>
<div className="mx-auto my-6">
{course?.content && <MDDisplay className="p-4 rounded-lg" source={course.content} />}
<CourseSidebar
lessons={uniqueLessons}
activeIndex={activeIndex}
onLessonSelect={handleLessonSelect}
completedLessons={completedLessons}
isMobileView={isMobileView}
sidebarVisible={sidebarVisible}
setSidebarVisible={setSidebarVisible}
hideToggleButton={true}
/>
</div>
) : (
<div className={`flex-shrink-0 transition-all duration-300 z-[999] ${
(isMobileView && activeTab === 'lessons') ? 'ml-0 w-auto opacity-100' :
'w-0 ml-0 opacity-0 overflow-hidden'
}`}>
<CourseSidebar
lessons={uniqueLessons}
activeIndex={activeIndex}
onLessonSelect={(index) => {
handleLessonSelect(index);
if (isMobileView) {
toggleTab(getTabItems().findIndex(item => item.label === 'Content'));
}
}}
completedLessons={completedLessons}
isMobileView={isMobileView}
onClose={() => {
setSidebarVisible(false);
setActiveTab('content');
}}
sidebarVisible={sidebarVisible}
setSidebarVisible={setSidebarVisible}
hideToggleButton={true}
/>
</div>
)}
</div>
</div>
</>
);

View File

@ -15,15 +15,11 @@ import { formatDateTime } from '@/utils/time';
import Image from 'next/image';
import useResponsiveImageDimensions from '@/hooks/useResponsiveImageDimensions';
import 'primeicons/primeicons.css';
import dynamic from 'next/dynamic';
import { validateEvent } from '@/utils/nostr';
import appConfig from '@/config/appConfig';
import { useIsAdmin } from '@/hooks/useIsAdmin';
import { useEncryptContent } from '@/hooks/encryption/useEncryptContent';
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
ssr: false,
});
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
export default function Draft() {
const [draft, setDraft] = useState(null);
@ -433,7 +429,7 @@ export default function Draft() {
</div>
</div>
<div className="w-[75vw] mx-auto mt-12 p-12 border-t-2 border-gray-300 max-tab:p-0 max-mob:p-0 max-tab:max-w-[100vw] max-mob:max-w-[100vw]">
{draft?.content && <MDDisplay className="p-4 rounded-lg" source={draft.content} />}
{draft?.content && <MarkdownDisplay content={draft.content} className="p-4 rounded-lg" />}
</div>
</div>
);