mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-05 00:32:03 +00:00
Merge pull request #64 from AustinKelsay/refactor/course-and-lesson-pages
Refactor/course and lesson pages
This commit is contained in:
commit
c72ce2d8ad
8768
package-lock.json
generated
8768
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -26,7 +26,6 @@
|
|||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@tanstack/react-query": "^5.51.21",
|
"@tanstack/react-query": "^5.51.21",
|
||||||
"@uiw/react-markdown-preview": "^5.1.2",
|
|
||||||
"@uiw/react-md-editor": "^3.11.0",
|
"@uiw/react-md-editor": "^3.11.0",
|
||||||
"@upstash/ratelimit": "^2.0.3",
|
"@upstash/ratelimit": "^2.0.3",
|
||||||
"@vercel/analytics": "^1.3.1",
|
"@vercel/analytics": "^1.3.1",
|
||||||
@ -38,6 +37,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"discord.js": "^14.15.3",
|
"discord.js": "^14.15.3",
|
||||||
|
"github-markdown-css": "^5.8.1",
|
||||||
"light-bolt11-decoder": "^3.1.1",
|
"light-bolt11-decoder": "^3.1.1",
|
||||||
"lucide-react": "^0.441.0",
|
"lucide-react": "^0.441.0",
|
||||||
"next": "^14.2.28",
|
"next": "^14.2.28",
|
||||||
@ -51,6 +51,7 @@
|
|||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { getSession, signIn, useSession } from 'next-auth/react';
|
import { getSession, signIn, useSession } from 'next-auth/react';
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Tooltip } from 'primereact/tooltip';
|
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import MoreInfo from '@/components/MoreInfo';
|
import MoreInfo from '@/components/MoreInfo';
|
||||||
|
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { useFetchGithubCommits } from '@/hooks/githubQueries/useFetchGithubCommits';
|
import { useFetchGithubCommits } from '@/hooks/githubQueries/useFetchGithubCommits';
|
||||||
import { Tooltip } from 'primereact/tooltip';
|
|
||||||
import { formatDateTime } from '@/utils/time';
|
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import MoreInfo from '@/components/MoreInfo';
|
import MoreInfo from '@/components/MoreInfo';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, use } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Carousel } from 'primereact/carousel';
|
import { Carousel } from 'primereact/carousel';
|
||||||
import { parseCourseEvent } from '@/utils/nostr';
|
import { parseCourseEvent } from '@/utils/nostr';
|
||||||
import { CourseTemplate } from '@/components/content/carousels/templates/CourseTemplate';
|
import { CourseTemplate } from '@/components/content/carousels/templates/CourseTemplate';
|
||||||
|
@ -6,7 +6,6 @@ import { DocumentTemplate } from '@/components/content/carousels/templates/Docum
|
|||||||
import TemplateSkeleton from '@/components/content/carousels/skeletons/TemplateSkeleton';
|
import TemplateSkeleton from '@/components/content/carousels/skeletons/TemplateSkeleton';
|
||||||
import { useDocuments } from '@/hooks/nostr/useDocuments';
|
import { useDocuments } from '@/hooks/nostr/useDocuments';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { Divider } from 'primereact/divider';
|
import { Divider } from 'primereact/divider';
|
||||||
const responsiveOptions = [
|
const responsiveOptions = [
|
||||||
{
|
{
|
||||||
|
@ -12,16 +12,12 @@ import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscripti
|
|||||||
import { getTotalFromZaps } from '@/utils/lightning';
|
import { getTotalFromZaps } from '@/utils/lightning';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
||||||
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const CombinedDetails = ({
|
const CombinedDetails = ({
|
||||||
processedEvent,
|
processedEvent,
|
||||||
@ -228,7 +224,7 @@ const CombinedDetails = ({
|
|||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (decryptedContent) {
|
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) {
|
if (paidResource && !decryptedContent) {
|
||||||
@ -256,7 +252,7 @@ const CombinedDetails = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (processedEvent?.content) {
|
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;
|
return null;
|
||||||
|
@ -7,18 +7,13 @@ import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
|
|||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { Divider } from 'primereact/divider';
|
import { Divider } from 'primereact/divider';
|
||||||
import { getTotalFromZaps } from '@/utils/lightning';
|
import { getTotalFromZaps } from '@/utils/lightning';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson';
|
import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson';
|
||||||
import { Menu } from 'primereact/menu';
|
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
||||||
const [zapAmount, setZapAmount] = useState(0);
|
const [zapAmount, setZapAmount] = useState(0);
|
||||||
@ -175,7 +170,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
if (isPaid && decryptionPerformed) {
|
if (isPaid && decryptionPerformed) {
|
||||||
return (
|
return (
|
||||||
<div ref={mdDisplayRef}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -217,7 +212,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
if (lesson?.content) {
|
if (lesson?.content) {
|
||||||
return (
|
return (
|
||||||
<div ref={mdDisplayRef}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -244,7 +239,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`${isVideo ? 'bg-gray-800/90 rounded-lg p-4 m-4' : 'w-full mx-auto px-4 py-8 -mt-32 relative z-10'}`}
|
className={`${isVideo ? 'bg-gray-800/90 rounded-lg p-4 m-4 max-mob:px-0' : 'w-full mx-auto px-4 py-8 -mt-32 relative z-10'}`}
|
||||||
>
|
>
|
||||||
<div className={`${!isVideo && 'mb-8 bg-gray-800/70 rounded-lg p-4'}`}>
|
<div className={`${!isVideo && 'mb-8 bg-gray-800/70 rounded-lg p-4'}`}>
|
||||||
<div className="flex flex-row items-center justify-between w-full">
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { Tag } from 'primereact/tag';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import CoursePaymentButton from '@/components/bitcoinConnect/CoursePaymentButton';
|
import CoursePaymentButton from '@/components/bitcoinConnect/CoursePaymentButton';
|
||||||
import ZapDisplay from '@/components/zaps/ZapDisplay';
|
|
||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
@ -20,7 +17,10 @@ import useTrackCourse from '@/hooks/tracking/useTrackCourse';
|
|||||||
import WelcomeModal from '@/components/onboarding/WelcomeModal';
|
import WelcomeModal from '@/components/onboarding/WelcomeModal';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import { Toast } from 'primereact/toast';
|
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({
|
export default function CourseDetails({
|
||||||
processedEvent,
|
processedEvent,
|
||||||
@ -29,6 +29,8 @@ export default function CourseDetails({
|
|||||||
decryptionPerformed,
|
decryptionPerformed,
|
||||||
handlePaymentSuccess,
|
handlePaymentSuccess,
|
||||||
handlePaymentError,
|
handlePaymentError,
|
||||||
|
isMobileView,
|
||||||
|
showCompletedTag = true,
|
||||||
}) {
|
}) {
|
||||||
const [zapAmount, setZapAmount] = useState(0);
|
const [zapAmount, setZapAmount] = useState(0);
|
||||||
const [author, setAuthor] = useState(null);
|
const [author, setAuthor] = useState(null);
|
||||||
@ -39,7 +41,8 @@ export default function CourseDetails({
|
|||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const windowWidth = useWindowWidth();
|
const windowWidth = useWindowWidth();
|
||||||
const isMobileView = windowWidth <= 768;
|
const localIsMobileView = windowWidth <= 768; // Use as fallback
|
||||||
|
const isPhone = isMobileView || localIsMobileView;
|
||||||
const { ndk } = useNDKContext();
|
const { ndk } = useNDKContext();
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
const toastRef = 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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full bg-gray-800 p-4 max-mob:px-0 rounded-lg">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
<WelcomeModal />
|
<WelcomeModal />
|
||||||
<div className="relative w-full h-[400px] mb-8">
|
|
||||||
<Image
|
<div className="flex flex-col">
|
||||||
alt="course image"
|
{isPhone ? (
|
||||||
src={returnImageProxy(processedEvent.image)}
|
<MobileCourseDetails {...detailsProps} />
|
||||||
fill
|
) : (
|
||||||
className="object-cover rounded-b-lg"
|
<DesktopCourseDetails {...detailsProps} />
|
||||||
/>
|
)}
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
82
src/components/content/courses/CourseHeader.js
Normal file
82
src/components/content/courses/CourseHeader.js
Normal 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;
|
@ -4,7 +4,6 @@ import Image from 'next/image';
|
|||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
import { getTotalFromZaps } from '@/utils/lightning';
|
import { getTotalFromZaps } from '@/utils/lightning';
|
||||||
import ZapDisplay from '@/components/zaps/ZapDisplay';
|
import ZapDisplay from '@/components/zaps/ZapDisplay';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
|
import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import useTrackDocumentLesson from '@/hooks/tracking/useTrackDocumentLesson';
|
import useTrackDocumentLesson from '@/hooks/tracking/useTrackDocumentLesson';
|
||||||
@ -13,10 +12,7 @@ import { nip19 } from 'nostr-tools';
|
|||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
||||||
const [zapAmount, setZapAmount] = useState(0);
|
const [zapAmount, setZapAmount] = useState(0);
|
||||||
@ -114,7 +110,7 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete
|
|||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isPaid && decryptionPerformed) {
|
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) {
|
if (isPaid && !decryptionPerformed) {
|
||||||
return (
|
return (
|
||||||
@ -124,7 +120,7 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (lesson?.content) {
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
145
src/components/content/courses/CourseSidebar.js
Normal file
145
src/components/content/courses/CourseSidebar.js
Normal 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;
|
148
src/components/content/courses/DesktopCourseDetails.js
Normal file
148
src/components/content/courses/DesktopCourseDetails.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
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
|
||||||
|
}) {
|
||||||
|
|
||||||
|
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"
|
||||||
|
href={`/profile/${author?.pubkey}`}
|
||||||
|
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?.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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -7,17 +7,13 @@ import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
|
|||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { Divider } from 'primereact/divider';
|
import { Divider } from 'primereact/divider';
|
||||||
import { getTotalFromZaps } from '@/utils/lightning';
|
import { getTotalFromZaps } from '@/utils/lightning';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import useTrackDocumentLesson from '@/hooks/tracking/useTrackDocumentLesson';
|
import useTrackDocumentLesson from '@/hooks/tracking/useTrackDocumentLesson';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
||||||
const [zapAmount, setZapAmount] = useState(0);
|
const [zapAmount, setZapAmount] = useState(0);
|
||||||
@ -118,11 +114,11 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isPaid && decryptionPerformed) {
|
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) {
|
if (isPaid && !decryptionPerformed) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800">
|
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center">
|
||||||
<div className="mx-auto py-auto">
|
<div className="mx-auto py-auto">
|
||||||
<i className="pi pi-lock text-[60px] text-red-500"></i>
|
<i className="pi pi-lock text-[60px] text-red-500"></i>
|
||||||
</div>
|
</div>
|
||||||
@ -133,7 +129,7 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (lesson?.content) {
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
@ -4,7 +4,6 @@ import { useImageProxy } from '@/hooks/useImageProxy';
|
|||||||
import { Tag } from 'primereact/tag';
|
import { Tag } from 'primereact/tag';
|
||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { nip04, nip19 } from 'nostr-tools';
|
import { nip04, nip19 } from 'nostr-tools';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
@ -18,10 +17,7 @@ import { validateEvent } from '@/utils/nostr';
|
|||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import { useEncryptContent } from '@/hooks/encryption/useEncryptContent';
|
import { useEncryptContent } from '@/hooks/encryption/useEncryptContent';
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function DraftCourseDetails({ processedEvent, draftId, lessons }) {
|
export default function DraftCourseDetails({ processedEvent, draftId, lessons }) {
|
||||||
const [author, setAuthor] = useState(null);
|
const [author, setAuthor] = useState(null);
|
||||||
@ -467,7 +463,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
|
|||||||
</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]">
|
<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 && (
|
{processedEvent?.content && (
|
||||||
<MDDisplay className="p-4 rounded-lg" source={processedEvent.content} />
|
<MarkdownDisplay content={processedEvent.content} className="p-4 rounded-lg" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,11 +6,7 @@ import Image from 'next/image';
|
|||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
import { formatDateTime, formatUnixTimestamp } from '@/utils/time';
|
import { formatDateTime, formatUnixTimestamp } from '@/utils/time';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import dynamic from 'next/dynamic';
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
|
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const DraftCourseLesson = ({ lesson, course }) => {
|
const DraftCourseLesson = ({ lesson, course }) => {
|
||||||
const [isPublished, setIsPublished] = useState(false);
|
const [isPublished, setIsPublished] = useState(false);
|
||||||
@ -149,7 +145,7 @@ const DraftCourseLesson = ({ lesson, course }) => {
|
|||||||
</div>
|
</div>
|
||||||
</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]">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
135
src/components/content/courses/MobileCourseDetails.js
Normal file
135
src/components/content/courses/MobileCourseDetails.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
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
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
{isCompleted && showCompletedTag && (
|
||||||
|
<Tag severity="success" value="Completed" />
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -6,7 +6,6 @@ import { useImageProxy } from '@/hooks/useImageProxy';
|
|||||||
import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
|
import { useZapsQuery } from '@/hooks/nostrQueries/zaps/useZapsQuery';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { getTotalFromZaps } from '@/utils/lightning';
|
import { getTotalFromZaps } from '@/utils/lightning';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { Divider } from 'primereact/divider';
|
import { Divider } from 'primereact/divider';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
@ -14,10 +13,7 @@ import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson';
|
|||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => {
|
||||||
const [zapAmount, setZapAmount] = useState(0);
|
const [zapAmount, setZapAmount] = useState(0);
|
||||||
@ -170,7 +166,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
|||||||
if (isPaid && decryptionPerformed) {
|
if (isPaid && decryptionPerformed) {
|
||||||
return (
|
return (
|
||||||
<div ref={mdDisplayRef}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (isPaid && !decryptionPerformed) {
|
} else if (isPaid && !decryptionPerformed) {
|
||||||
@ -196,7 +192,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
|||||||
} else if (lesson?.content) {
|
} else if (lesson?.content) {
|
||||||
return (
|
return (
|
||||||
<div ref={mdDisplayRef}>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -208,7 +204,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
|||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="bg-gray-800/90 rounded-lg p-4 m-4">
|
<div className="bg-gray-800/90 rounded-lg p-4 m-4 max-mob:px-2">
|
||||||
<div className="w-full flex flex-col items-start justify-start mt-2 px-2">
|
<div className="w-full flex flex-col items-start justify-start mt-2 px-2">
|
||||||
<div className="flex flex-row items-center justify-between w-full">
|
<div className="flex flex-row items-center justify-between w-full">
|
||||||
<h1 className="text-3xl text-white">{lesson.title}</h1>
|
<h1 className="text-3xl text-white">{lesson.title}</h1>
|
||||||
|
@ -12,16 +12,12 @@ import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscripti
|
|||||||
import { getTotalFromZaps } from '@/utils/lightning';
|
import { getTotalFromZaps } from '@/utils/lightning';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
||||||
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const DocumentDetails = ({
|
const DocumentDetails = ({
|
||||||
processedEvent,
|
processedEvent,
|
||||||
@ -234,7 +230,7 @@ const DocumentDetails = ({
|
|||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (decryptedContent) {
|
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) {
|
if (paidResource && !decryptedContent) {
|
||||||
return (
|
return (
|
||||||
@ -260,7 +256,7 @@ const DocumentDetails = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (processedEvent?.content) {
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import CommunityMessage from '@/components/feeds/messages/CommunityMessage';
|
|
||||||
import { parseMessageEvent } from '@/utils/nostr';
|
import { parseMessageEvent } from '@/utils/nostr';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import { useNDKContext } from '@/context/NDKContext';
|
import { useNDKContext } from '@/context/NDKContext';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
|
@ -12,17 +12,13 @@ import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscripti
|
|||||||
import { getTotalFromZaps } from '@/utils/lightning';
|
import { getTotalFromZaps } from '@/utils/lightning';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
import MoreOptionsMenu from '@/components/ui/MoreOptionsMenu';
|
||||||
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const VideoDetails = ({
|
const VideoDetails = ({
|
||||||
processedEvent,
|
processedEvent,
|
||||||
@ -234,7 +230,7 @@ const VideoDetails = ({
|
|||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (decryptedContent) {
|
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) {
|
if (paidResource && !decryptedContent) {
|
||||||
return (
|
return (
|
||||||
@ -267,7 +263,7 @@ const VideoDetails = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (processedEvent?.content) {
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
@ -276,7 +272,7 @@ const VideoDetails = ({
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Toast ref={toastRef} />
|
<Toast ref={toastRef} />
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
<div className="bg-gray-800/90 rounded-lg p-4 m-4 max-mob:m-0 max-tab:m-0 max-mob:rounded-t-none max-tab:rounded-t-none">
|
<div className="bg-gray-800/90 rounded-lg p-4 m-4 max-mob:px-2 max-tab:px-2 max-mob:m-0 max-tab:m-0 max-mob:rounded-t-none max-tab:rounded-t-none">
|
||||||
<div className={`w-full flex flex-col items-start justify-start mt-2 px-2`}>
|
<div className={`w-full flex flex-col items-start justify-start mt-2 px-2`}>
|
||||||
<div className="flex flex-col items-start gap-2 w-full">
|
<div className="flex flex-col items-start gap-2 w-full">
|
||||||
<div className="flex flex-row items-center justify-between gap-2 w-full">
|
<div className="flex flex-row items-center justify-between gap-2 w-full">
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { InputTextarea } from 'primereact/inputtextarea';
|
import { InputTextarea } from 'primereact/inputtextarea';
|
||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import { useNDKContext } from '@/context/NDKContext';
|
import { useNDKContext } from '@/context/NDKContext';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import axios from 'axios';
|
|
||||||
import { InputText } from 'primereact/inputtext';
|
import { InputText } from 'primereact/inputtext';
|
||||||
import { InputNumber } from 'primereact/inputnumber';
|
import { InputNumber } from 'primereact/inputnumber';
|
||||||
import { InputSwitch } from 'primereact/inputswitch';
|
import { InputSwitch } from 'primereact/inputswitch';
|
||||||
|
27
src/components/markdown/MarkdownDisplay.js
Normal file
27
src/components/markdown/MarkdownDisplay.js
Normal 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;
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import { TabMenu } from 'primereact/tabmenu';
|
import { TabMenu } from 'primereact/tabmenu';
|
||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
@ -1,25 +1,42 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { TabMenu } from 'primereact/tabmenu';
|
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 (
|
return (
|
||||||
<div className="w-[100%]">
|
<div className="w-[100%] relative">
|
||||||
<TabMenu
|
<TabMenu
|
||||||
className="w-full bg-transparent border-none"
|
className="w-full"
|
||||||
model={items}
|
model={items}
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
onTabChange={e => onTabChange(e.index)}
|
onTabChange={e => onTabChange(e.index)}
|
||||||
pt={{
|
pt={{
|
||||||
tabmenu: {
|
menu: { className: 'bg-transparent border-none my-2 py-1' },
|
||||||
menu: ({ context }) => ({
|
action: ({ context, parent }) => ({
|
||||||
className: 'bg-transparent border-none',
|
className:
|
||||||
}),
|
'cursor-pointer select-none flex items-center relative no-underline overflow-hidden p-4 font-bold rounded-t-lg',
|
||||||
action: ({ context }) => ({
|
style: { top: '2px' },
|
||||||
className: 'bg-transparent border-none',
|
})
|
||||||
}),
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,10 @@ 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 { useNDKContext } from '@/context/NDKContext';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
import { parseCourseEvent } from '@/utils/nostr';
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -17,6 +21,54 @@ const Navbar = () => {
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [showMobileSearch, setShowMobileSearch] = useState(false);
|
const [showMobileSearch, setShowMobileSearch] = useState(false);
|
||||||
const menu = useRef(null);
|
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;
|
||||||
|
if (!slug) return; // still preparing
|
||||||
|
|
||||||
|
const slugStr = Array.isArray(slug) ? slug[0] : slug;
|
||||||
|
let identifier;
|
||||||
|
|
||||||
|
if (slugStr.includes('naddr')) {
|
||||||
|
const { data } = nip19.decode(slugStr);
|
||||||
|
identifier = data?.identifier;
|
||||||
|
} else {
|
||||||
|
identifier = slugStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Lock/unlock body scroll when mobile search is shown/hidden
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -65,21 +117,33 @@ const Navbar = () => {
|
|||||||
>
|
>
|
||||||
{/* Left section */}
|
{/* Left section */}
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
<div
|
{isCoursePage && course ? (
|
||||||
onClick={() => router.push('/')}
|
/* Course header in navbar mode */
|
||||||
className="flex flex-row items-center justify-center cursor-pointer hover:opacity-80"
|
<CourseHeader
|
||||||
>
|
course={course}
|
||||||
<Image
|
isMobileView={windowWidth <= 600}
|
||||||
alt="logo"
|
isCompleted={isCompleted}
|
||||||
src="/images/plebdevs-icon.png"
|
isNavbarMode={true}
|
||||||
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
|
/* Regular PlebDevs branding */
|
||||||
</h1>
|
<div
|
||||||
</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 ? (
|
{windowWidth > 600 ? (
|
||||||
<div
|
<div
|
||||||
className={`ml-2 p-2 cursor-pointer transition-all duration-300 flex items-center justify-center ${isHovered ? 'bg-gray-700 rounded-full' : ''}`}
|
className={`ml-2 p-2 cursor-pointer transition-all duration-300 flex items-center justify-center ${isHovered ? 'bg-gray-700 rounded-full' : ''}`}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { DataTable } from 'primereact/datatable';
|
import { DataTable } from 'primereact/datatable';
|
||||||
import { Column } from 'primereact/column';
|
import { Column } from 'primereact/column';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
|
||||||
import ProgressListItem from '@/components/content/lists/ProgressListItem';
|
import ProgressListItem from '@/components/content/lists/ProgressListItem';
|
||||||
import { formatDateTime } from '@/utils/time';
|
import { formatDateTime } from '@/utils/time';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Menu } from 'primereact/menu';
|
|
||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Menu } from 'primereact/menu';
|
import { Menu } from 'primereact/menu';
|
||||||
import { Tooltip } from 'primereact/tooltip';
|
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Dialog } from 'primereact/dialog';
|
import { Dialog } from 'primereact/dialog';
|
||||||
import { Button } from 'primereact/button';
|
import { Button } from 'primereact/button';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
|
@ -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 { OverlayPanel } from 'primereact/overlaypanel';
|
||||||
import ZapForm from './ZapForm';
|
import ZapForm from './ZapForm';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ const appConfig = {
|
|||||||
],
|
],
|
||||||
authorPubkeys: [
|
authorPubkeys: [
|
||||||
'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741',
|
'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741',
|
||||||
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345',
|
'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345'
|
||||||
],
|
],
|
||||||
customLightningAddresses: [
|
customLightningAddresses: [
|
||||||
{
|
{
|
||||||
|
@ -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 NDK, { NDKNip07Signer } from '@nostr-dev-kit/ndk';
|
||||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
|
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { PrimeReactProvider } from 'primereact/api';
|
import { PrimeReactProvider } from 'primereact/api';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import Navbar from '@/components/navbar/Navbar';
|
import Navbar from '@/components/navbar/Navbar';
|
||||||
import { ToastProvider } from '@/hooks/useToast';
|
import { ToastProvider } from '@/hooks/useToast';
|
||||||
import { SessionProvider } from 'next-auth/react';
|
import { SessionProvider } from 'next-auth/react';
|
||||||
@ -10,8 +9,6 @@ import '@/styles/custom-theme.css'; // custom theme
|
|||||||
import 'primereact/resources/primereact.min.css';
|
import 'primereact/resources/primereact.min.css';
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css';
|
||||||
import '@uiw/react-md-editor/markdown-editor.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 { NDKProvider } from '@/context/NDKContext';
|
||||||
import { Analytics } from '@vercel/analytics/react';
|
import { Analytics } from '@vercel/analytics/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
@ -20,7 +17,6 @@ import BottomBar from '@/components/BottomBar';
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export default function MyApp({ Component, pageProps: { session, ...pageProps } }) {
|
export default function MyApp({ Component, pageProps: { session, ...pageProps } }) {
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimeReactProvider>
|
<PrimeReactProvider>
|
||||||
|
@ -1,26 +1,23 @@
|
|||||||
import React, { useEffect, useState, useCallback } 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 { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr';
|
||||||
import CourseDetails from '@/components/content/courses/CourseDetails';
|
import CourseDetails from '@/components/content/courses/CourseDetails';
|
||||||
import VideoLesson from '@/components/content/courses/VideoLesson';
|
import VideoLesson from '@/components/content/courses/VideoLesson';
|
||||||
import DocumentLesson from '@/components/content/courses/DocumentLesson';
|
import DocumentLesson from '@/components/content/courses/DocumentLesson';
|
||||||
import CombinedLesson from '@/components/content/courses/CombinedLesson';
|
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 axios from 'axios';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { nip04, 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 { Accordion, AccordionTab } from 'primereact/accordion';
|
|
||||||
import { Tag } from 'primereact/tag';
|
|
||||||
import { useDecryptContent } from '@/hooks/encryption/useDecryptContent';
|
import { useDecryptContent } from '@/hooks/encryption/useDecryptContent';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
import MenuTab from '@/components/menutab/MenuTab';
|
||||||
ssr: false,
|
import { Tag } from 'primereact/tag';
|
||||||
});
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
|
|
||||||
const useCourseData = (ndk, fetchAuthor, router) => {
|
const useCourseData = (ndk, fetchAuthor, router) => {
|
||||||
const [course, setCourse] = useState(null);
|
const [course, setCourse] = useState(null);
|
||||||
@ -93,37 +90,41 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
|||||||
const [lessons, setLessons] = useState([]);
|
const [lessons, setLessons] = useState([]);
|
||||||
const [uniqueLessons, setUniqueLessons] = useState([]);
|
const [uniqueLessons, setUniqueLessons] = useState([]);
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lessonIds.length > 0) {
|
if (lessonIds.length > 0 && pubkey) {
|
||||||
const fetchLesson = async lessonId => {
|
const fetchLessons = async () => {
|
||||||
try {
|
try {
|
||||||
await ndk.connect();
|
await ndk.connect();
|
||||||
|
|
||||||
|
// Create a single filter with all lesson IDs to avoid multiple calls
|
||||||
const filter = {
|
const filter = {
|
||||||
'#d': [lessonId],
|
'#d': lessonIds,
|
||||||
kinds: [30023, 30402],
|
kinds: [30023, 30402],
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
};
|
};
|
||||||
const event = await ndk.fetchEvent(filter);
|
|
||||||
if (event) {
|
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 author = await fetchAuthor(event.pubkey);
|
||||||
const parsedLesson = { ...parseEvent(event), author };
|
const parsedLesson = { ...parseEvent(event), author };
|
||||||
setLessons(prev => {
|
newLessons.push(parsedLesson);
|
||||||
// Check if the lesson already exists in the array
|
|
||||||
const exists = prev.some(lesson => lesson.id === parsedLesson.id);
|
|
||||||
if (!exists) {
|
|
||||||
return [...prev, parsedLesson];
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLessons(newLessons);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching event:', error);
|
console.error('Error fetching events:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
lessonIds.forEach(lessonId => fetchLesson(lessonId));
|
|
||||||
|
fetchLessons();
|
||||||
}
|
}
|
||||||
}, [lessonIds, ndk, fetchAuthor, pubkey]);
|
}, [lessonIds, ndk, fetchAuthor, pubkey]);
|
||||||
|
|
||||||
|
// Keep this deduplication logic using Map
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newUniqueLessons = Array.from(
|
const newUniqueLessons = Array.from(
|
||||||
new Map(lessons.map(lesson => [lesson.id, lesson])).values()
|
new Map(lessons.map(lesson => [lesson.id, lesson])).values()
|
||||||
@ -177,11 +178,61 @@ const Course = () => {
|
|||||||
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 [expandedIndex, setExpandedIndex] = useState(null);
|
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 windowWidth = useWindowWidth();
|
||||||
|
const isMobileView = windowWidth <= 968;
|
||||||
|
const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab
|
||||||
|
const navbarHeight = 60; // Match the height from Navbar component
|
||||||
|
|
||||||
|
// Memoized function to get the tab map based on view mode
|
||||||
|
const getTabMap = useMemo(() => {
|
||||||
|
const baseTabMap = ['overview', 'content', 'qa'];
|
||||||
|
if (isMobileView) {
|
||||||
|
const mobileTabMap = [...baseTabMap];
|
||||||
|
mobileTabMap.splice(2, 0, 'lessons');
|
||||||
|
return mobileTabMap;
|
||||||
|
}
|
||||||
|
return baseTabMap;
|
||||||
|
}, [isMobileView]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.isReady && router.query.slug) {
|
||||||
|
const { slug } = router.query;
|
||||||
|
if (slug.includes('naddr')) {
|
||||||
|
setNAddress(slug);
|
||||||
|
} else {
|
||||||
|
console.warn('No naddress found in slug');
|
||||||
|
showToast('error', 'Error', 'Course identifier not found in URL');
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/courses'); // Redirect to courses page
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [router.isReady, router.query.slug, showToast, 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]);
|
||||||
|
|
||||||
const setCompleted = useCallback(lessonId => {
|
const setCompleted = useCallback(lessonId => {
|
||||||
setCompletedLessons(prev => [...prev, lessonId]);
|
setCompletedLessons(prev => [...prev, lessonId]);
|
||||||
@ -203,12 +254,14 @@ const Course = () => {
|
|||||||
paidCourse,
|
paidCourse,
|
||||||
loading: courseLoading,
|
loading: courseLoading,
|
||||||
} = useCourseData(ndk, fetchAuthor, router);
|
} = useCourseData(ndk, fetchAuthor, router);
|
||||||
|
|
||||||
const { lessons, uniqueLessons, setLessons } = useLessons(
|
const { lessons, uniqueLessons, setLessons } = useLessons(
|
||||||
ndk,
|
ndk,
|
||||||
fetchAuthor,
|
fetchAuthor,
|
||||||
lessonIds,
|
lessonIds,
|
||||||
course?.pubkey
|
course?.pubkey
|
||||||
);
|
);
|
||||||
|
|
||||||
const { decryptionPerformed, loading: decryptionLoading } = useDecryption(
|
const { decryptionPerformed, loading: decryptionLoading } = useDecryption(
|
||||||
session,
|
session,
|
||||||
paidCourse,
|
paidCourse,
|
||||||
@ -217,17 +270,6 @@ const Course = () => {
|
|||||||
setLessons
|
setLessons
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (router.isReady) {
|
|
||||||
const { active } = router.query;
|
|
||||||
if (active !== undefined) {
|
|
||||||
setExpandedIndex(parseInt(active, 10));
|
|
||||||
} else {
|
|
||||||
setExpandedIndex(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [router.isReady, router.query]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (uniqueLessons.length > 0) {
|
if (uniqueLessons.length > 0) {
|
||||||
const addresses = {};
|
const addresses = {};
|
||||||
@ -258,14 +300,20 @@ const Course = () => {
|
|||||||
}
|
}
|
||||||
}, [session]);
|
}, [session]);
|
||||||
|
|
||||||
const handleAccordionChange = e => {
|
const isAuthorized =
|
||||||
const newIndex = e.index === expandedIndex ? null : e.index;
|
session?.user?.role?.subscribed ||
|
||||||
setExpandedIndex(newIndex);
|
session?.user?.pubkey === course?.pubkey ||
|
||||||
|
!paidCourse ||
|
||||||
|
session?.user?.purchased?.some(purchase => purchase.courseId === course?.d)
|
||||||
|
|
||||||
if (newIndex !== null) {
|
const handleLessonSelect = index => {
|
||||||
router.push(`/course/${router.query.slug}?active=${newIndex}`, undefined, { shallow: true });
|
setActiveIndex(index);
|
||||||
} else {
|
router.push(`/course/${router.query.slug}?active=${index}`, undefined, { shallow: true });
|
||||||
router.push(`/course/${router.query.slug}`, undefined, { shallow: true });
|
|
||||||
|
// On mobile, switch to content tab after selection
|
||||||
|
if (isMobileView) {
|
||||||
|
setActiveTab('content');
|
||||||
|
setSidebarVisible(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -286,6 +334,141 @@ const Course = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleTab = (index) => {
|
||||||
|
const tabName = getTabMap[index];
|
||||||
|
setActiveTab(tabName);
|
||||||
|
|
||||||
|
// Only show/hide sidebar on mobile - desktop keeps sidebar visible
|
||||||
|
if (isMobileView) {
|
||||||
|
setSidebarVisible(tabName === 'lessons');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleSidebar = () => {
|
||||||
|
setSidebarVisible(!sidebarVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map active tab name back to index for MenuTab
|
||||||
|
const getActiveTabIndex = () => {
|
||||||
|
return getTabMap.indexOf(activeTab);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create tab items for MenuTab
|
||||||
|
const getTabItems = () => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
icon: 'pi pi-home',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content',
|
||||||
|
icon: 'pi pi-book',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add lessons tab only on mobile
|
||||||
|
if (isMobileView) {
|
||||||
|
items.push({
|
||||||
|
label: 'Lessons',
|
||||||
|
icon: 'pi pi-list',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
label: 'Comments',
|
||||||
|
icon: 'pi pi-comments',
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add keyboard navigation support for tabs
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
const currentIndex = getActiveTabIndex();
|
||||||
|
const nextIndex = (currentIndex + 1) % getTabMap.length;
|
||||||
|
toggleTab(nextIndex);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
const currentIndex = getActiveTabIndex();
|
||||||
|
const prevIndex = (currentIndex - 1 + getTabMap.length) % getTabMap.length;
|
||||||
|
toggleTab(prevIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [activeTab, getTabMap, toggleTab]);
|
||||||
|
|
||||||
|
// Render the QA section (empty for now)
|
||||||
|
const renderQASection = () => {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg p-8 mt-4 bg-gray-800 max-mob:px-4">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Comments</h2>
|
||||||
|
{nAddress !== null && isAuthorized ? (
|
||||||
|
<div className="px-4 max-mob:px-0">
|
||||||
|
<ZapThreadsWrapper
|
||||||
|
anchor={nAddress}
|
||||||
|
user={nsec || npub || null}
|
||||||
|
relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/"
|
||||||
|
disable="zaps"
|
||||||
|
isAuthorized={isAuthorized}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 mx-4 bg-gray-800/50 rounded-lg">
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Comments are only available to content purchasers, subscribers, and the content creator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// Render Course Overview section
|
||||||
|
const renderOverviewSection = () => {
|
||||||
|
// Get isCompleted status for use in the component
|
||||||
|
const isCompleted = completedLessons.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-800 rounded-lg border border-gray-800 shadow-md ${isMobileView ? 'p-4' : 'p-6'}`}>
|
||||||
|
{isMobileView && course && (
|
||||||
|
<div className="mb-2">
|
||||||
|
{/* Completed tag above image in mobile view */}
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<Tag severity="success" value="Completed" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Course image */}
|
||||||
|
{course.image && (
|
||||||
|
<div className="w-full h-48 relative rounded-lg overflow-hidden mb-3">
|
||||||
|
<img
|
||||||
|
src={course.image}
|
||||||
|
alt={course.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<CourseDetails
|
||||||
|
processedEvent={course}
|
||||||
|
paidCourse={paidCourse}
|
||||||
|
lessons={uniqueLessons}
|
||||||
|
decryptionPerformed={decryptionPerformed}
|
||||||
|
handlePaymentSuccess={handlePaymentSuccess}
|
||||||
|
handlePaymentError={handlePaymentError}
|
||||||
|
isMobileView={isMobileView}
|
||||||
|
showCompletedTag={!isMobileView}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (courseLoading || decryptionLoading) {
|
if (courseLoading || 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">
|
||||||
@ -304,6 +487,7 @@ const Course = () => {
|
|||||||
isPaid={paidCourse}
|
isPaid={paidCourse}
|
||||||
setCompleted={setCompleted}
|
setCompleted={setCompleted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
);
|
);
|
||||||
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
|
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
|
||||||
return (
|
return (
|
||||||
@ -330,72 +514,118 @@ const Course = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{course && paidCourse !== null && (
|
<div className="mx-auto px-8 max-mob:px-0 mb-12 mt-2">
|
||||||
<CourseDetails
|
{/* Tab navigation using MenuTab component */}
|
||||||
processedEvent={course}
|
<div className="z-10 bg-transparent"
|
||||||
paidCourse={paidCourse}
|
style={{
|
||||||
lessons={uniqueLessons}
|
top: `${navbarHeight}px`,
|
||||||
decryptionPerformed={decryptionPerformed}
|
height: `${navbarHeight}px`
|
||||||
handlePaymentSuccess={handlePaymentSuccess}
|
}}>
|
||||||
handlePaymentError={handlePaymentError}
|
<MenuTab
|
||||||
/>
|
items={getTabItems()}
|
||||||
)}
|
activeIndex={getActiveTabIndex()}
|
||||||
<Accordion
|
onTabChange={(index) => toggleTab(index)}
|
||||||
activeIndex={expandedIndex}
|
sidebarVisible={sidebarVisible}
|
||||||
onTabChange={handleAccordionChange}
|
onToggleSidebar={handleToggleSidebar}
|
||||||
className="mt-4 px-4 max-mob:px-0 max-tab:px-0"
|
isMobileView={isMobileView}
|
||||||
>
|
/>
|
||||||
{uniqueLessons.length > 0 &&
|
</div>
|
||||||
uniqueLessons.map((lesson, index) => (
|
|
||||||
<AccordionTab
|
{/* Revised layout structure to prevent content flexing */}
|
||||||
key={index}
|
<div className="relative mt-4">
|
||||||
pt={{
|
{/* Main content area with fixed width */}
|
||||||
root: { className: 'border-none' },
|
<div className={`transition-all duration-500 ease-in-out ${isMobileView ? 'w-full' : 'w-full'}`}
|
||||||
header: { className: 'border-none' },
|
style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}>
|
||||||
headerAction: { className: 'border-none' },
|
{/* Overview tab content */}
|
||||||
content: { className: 'border-none max-mob:px-0 max-tab:px-0' },
|
<div className={`${activeTab === 'overview' ? 'block' : 'hidden'}`}>
|
||||||
accordiontab: { className: 'border-none' },
|
{renderOverviewSection()}
|
||||||
}}
|
</div>
|
||||||
header={
|
|
||||||
<div className="flex align-items-center justify-between w-full">
|
{/* Content tab content */}
|
||||||
<span
|
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
|
||||||
id={`lesson-${index}`}
|
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
|
||||||
className="font-bold text-xl"
|
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||||
>{`Lesson ${index + 1}: ${lesson.title}`}</span>
|
{renderLesson(uniqueLessons[activeIndex])}
|
||||||
{completedLessons.includes(lesson.id) ? (
|
|
||||||
<Tag severity="success" value="Completed" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
) : (
|
||||||
|
<div className="text-center bg-gray-800 rounded-lg p-8">
|
||||||
|
<p>Select a lesson from the sidebar to begin learning.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{course?.content && (
|
||||||
|
<div className="mt-8 bg-gray-800 rounded-lg shadow-sm">
|
||||||
|
<MarkdownDisplay content={course.content} className="p-4 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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">
|
<CourseSidebar
|
||||||
{renderLesson(lesson)}
|
lessons={uniqueLessons}
|
||||||
{nAddresses[lesson.id] && (
|
activeIndex={activeIndex}
|
||||||
<div className="mt-8">
|
onLessonSelect={(index) => {
|
||||||
{!paidCourse || decryptionPerformed || session?.user?.role?.subscribed ? (
|
handleLessonSelect(index);
|
||||||
<ZapThreadsWrapper
|
if (isMobileView) {
|
||||||
anchor={nAddresses[lesson.id]}
|
setActiveTab('content'); // Use the tab name directly
|
||||||
user={session?.user ? nsec || npub : null}
|
}
|
||||||
relays={appConfig.defaultRelayUrls.join(',')}
|
}}
|
||||||
disable="zaps"
|
completedLessons={completedLessons}
|
||||||
isAuthorized={true}
|
isMobileView={isMobileView}
|
||||||
/>
|
sidebarVisible={sidebarVisible}
|
||||||
) : (
|
setSidebarVisible={setSidebarVisible}
|
||||||
<div className="text-center p-4 bg-gray-800/50 rounded-lg">
|
hideToggleButton={true}
|
||||||
<p className="text-gray-400">
|
/>
|
||||||
Comments are only available to course purchasers, subscribers, and the
|
</div>
|
||||||
course creator.
|
) : (
|
||||||
</p>
|
<div className={`flex-shrink-0 transition-all duration-300 z-[999] ${
|
||||||
</div>
|
(isMobileView && activeTab === 'lessons') ? 'ml-0 w-auto opacity-100' :
|
||||||
)}
|
'w-0 ml-0 opacity-0 overflow-hidden'
|
||||||
</div>
|
}`}>
|
||||||
)}
|
<CourseSidebar
|
||||||
</div>
|
lessons={uniqueLessons}
|
||||||
</AccordionTab>
|
activeIndex={activeIndex}
|
||||||
))}
|
onLessonSelect={(index) => {
|
||||||
</Accordion>
|
handleLessonSelect(index);
|
||||||
<div className="mx-auto my-6">
|
if (isMobileView) {
|
||||||
{course?.content && <MDDisplay className="p-4 rounded-lg" source={course.content} />}
|
setActiveTab('content'); // Use the tab name directly
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
completedLessons={completedLessons}
|
||||||
|
isMobileView={isMobileView}
|
||||||
|
onClose={() => {
|
||||||
|
setSidebarVisible(false);
|
||||||
|
setActiveTab('content');
|
||||||
|
}}
|
||||||
|
sidebarVisible={sidebarVisible}
|
||||||
|
setSidebarVisible={setSidebarVisible}
|
||||||
|
hideToggleButton={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -15,15 +15,11 @@ import { formatDateTime } from '@/utils/time';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import useResponsiveImageDimensions from '@/hooks/useResponsiveImageDimensions';
|
import useResponsiveImageDimensions from '@/hooks/useResponsiveImageDimensions';
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css';
|
||||||
import dynamic from 'next/dynamic';
|
|
||||||
import { validateEvent } from '@/utils/nostr';
|
import { validateEvent } from '@/utils/nostr';
|
||||||
import appConfig from '@/config/appConfig';
|
import appConfig from '@/config/appConfig';
|
||||||
import { useIsAdmin } from '@/hooks/useIsAdmin';
|
import { useIsAdmin } from '@/hooks/useIsAdmin';
|
||||||
import { useEncryptContent } from '@/hooks/encryption/useEncryptContent';
|
import { useEncryptContent } from '@/hooks/encryption/useEncryptContent';
|
||||||
|
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||||
const MDDisplay = dynamic(() => import('@uiw/react-markdown-preview'), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function Draft() {
|
export default function Draft() {
|
||||||
const [draft, setDraft] = useState(null);
|
const [draft, setDraft] = useState(null);
|
||||||
@ -433,7 +429,7 @@ export default function Draft() {
|
|||||||
</div>
|
</div>
|
||||||
</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]">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -191,7 +191,7 @@ export default function Home() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full px-4">
|
<div className="w-full px-4 max-mob:px-0">
|
||||||
<CoursesCarousel />
|
<CoursesCarousel />
|
||||||
<VideosCarousel />
|
<VideosCarousel />
|
||||||
<DocumentsCarousel />
|
<DocumentsCarousel />
|
||||||
|
Loading…
x
Reference in New Issue
Block a user