diff --git a/src/components/content/carousels/newTemplates/CourseTemplate.js b/src/components/content/carousels/newTemplates/CourseTemplate.js index 01fd8eb..4b9c78a 100644 --- a/src/components/content/carousels/newTemplates/CourseTemplate.js +++ b/src/components/content/carousels/newTemplates/CourseTemplate.js @@ -2,19 +2,21 @@ import { useState, useEffect } from "react"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Tag } from "primereact/tag"; import ZapDisplay from "@/components/zaps/ZapDisplay"; -import { BookOpen } from "lucide-react" +import { nip19 } from "nostr-tools"; import Image from "next/image" import { useZapsSubscription } from "@/hooks/nostrQueries/zaps/useZapsSubscription"; import { getTotalFromZaps } from "@/utils/lightning"; import { useImageProxy } from "@/hooks/useImageProxy"; import { useRouter } from "next/router"; import { formatTimestampToHowLongAgo } from "@/utils/time"; +import { ProgressSpinner } from "primereact/progressspinner"; import GenericButton from "@/components/buttons/GenericButton"; export function CourseTemplate({ course }) { const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: course }); const [zapAmount, setZapAmount] = useState(0); const [lessonCount, setLessonCount] = useState(0); + const [nAddress, setNAddress] = useState(null); const router = useRouter(); const { returnImageProxy } = useImageProxy(); @@ -32,6 +34,19 @@ export function CourseTemplate({ course }) { } }, [course]); + useEffect(() => { + if (course && course?.id) { + const nAddress = nip19.naddrEncode({ + pubkey: course.pubkey, + kind: course.kind, + identifier: course.id, + }); + setNAddress(nAddress); + } + }, [course]); + + if (!nAddress) return ; + if (zapsError) return
Error: {zapsError}
; return ( @@ -82,7 +97,7 @@ export function CourseTemplate({ course }) { ) : ( formatTimestampToHowLongAgo(course.created_at) )}

- router.push(`/course/${course.id}`)} size="small" label="Start Learning" icon="pi pi-chevron-right" iconPos="right" outlined className="items-center py-2" /> + router.push(`/course/${nAddress}`)} size="small" label="Start Learning" icon="pi pi-chevron-right" iconPos="right" outlined className="items-center py-2" /> ) diff --git a/src/components/content/courses/CourseDetailsNew.js b/src/components/content/courses/CourseDetailsNew.js index 716fff0..375d9b5 100644 --- a/src/components/content/courses/CourseDetailsNew.js +++ b/src/components/content/courses/CourseDetailsNew.js @@ -7,12 +7,12 @@ import { useRouter } from 'next/router'; import CoursePaymentButton from "@/components/bitcoinConnect/CoursePaymentButton"; import ZapDisplay from '@/components/zaps/ZapDisplay'; import GenericButton from '@/components/buttons/GenericButton'; +import { nip19 } from 'nostr-tools'; import { useImageProxy } from '@/hooks/useImageProxy'; import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription'; import { getTotalFromZaps } from '@/utils/lightning'; import { useSession } from 'next-auth/react'; import useWindowWidth from "@/hooks/useWindowWidth"; -import dynamic from 'next/dynamic'; import { useNDKContext } from "@/context/NDKContext"; import { findKind0Fields } from '@/utils/nostr'; @@ -21,6 +21,7 @@ const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS; export default function CourseDetailsNew({ processedEvent, paidCourse, lessons, decryptionPerformed, handlePaymentSuccess, handlePaymentError }) { const [zapAmount, setZapAmount] = useState(0); const [author, setAuthor] = useState(null); + const [nAddress, setNAddress] = useState(null); const router = useRouter(); const { returnImageProxy } = useImageProxy(); const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: processedEvent }); @@ -39,6 +40,17 @@ export default function CourseDetailsNew({ processedEvent, paidCourse, lessons, } }, [ndk]); + useEffect(() => { + if (processedEvent) { + const naddr = nip19.naddrEncode({ + pubkey: processedEvent.pubkey, + kind: processedEvent.kind, + identifier: processedEvent.d, + }); + setNAddress(naddr); + } + }, [processedEvent]); + useEffect(() => { if (processedEvent) { fetchAuthor(processedEvent.pubkey); @@ -151,6 +163,11 @@ export default function CourseDetailsNew({ processedEvent, paidCourse, lessons, )} + {nAddress && ( +
+ window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip="View Nostr Event" tooltipOptions={{ position: 'left' }} /> +
+ )} diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index 2c25e88..99aaa07 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.js @@ -4,6 +4,9 @@ import Image from "next/image"; import ZapDisplay from "@/components/zaps/ZapDisplay"; import { useImageProxy } from "@/hooks/useImageProxy"; import { useZapsQuery } from "@/hooks/nostrQueries/zaps/useZapsQuery"; +import GenericButton from "@/components/buttons/GenericButton"; +import { nip19 } from "nostr-tools"; +import { Divider } from "primereact/divider"; import { getTotalFromZaps } from "@/utils/lightning"; import dynamic from "next/dynamic"; @@ -16,6 +19,7 @@ const MDDisplay = dynamic( const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { const [zapAmount, setZapAmount] = useState(0); + const [nAddress, setNAddress] = useState(null); const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" }); const { returnImageProxy } = useImageProxy(); @@ -25,6 +29,17 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { setZapAmount(total); }, [zaps, zapsLoading, zapsError, lesson]); + useEffect(() => { + if (lesson) { + const addr = nip19.naddrEncode({ + pubkey: lesson.pubkey, + kind: lesson.kind, + identifier: lesson.d, + }) + setNAddress(addr); + } + }, [lesson]); + const renderContent = () => { if (isPaid && decryptionPerformed) { return ; @@ -93,7 +108,19 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { zapsLoading={zapsLoading} /> +
+ { + window.open(`https://nostr.com/${nAddress}`, '_blank'); + }} + /> +
+ {renderContent()} {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index 39b9519..25795ae 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -3,9 +3,12 @@ import { Tag } from "primereact/tag"; import Image from "next/image"; import ZapDisplay from "@/components/zaps/ZapDisplay"; import { useImageProxy } from "@/hooks/useImageProxy"; +import GenericButton from "@/components/buttons/GenericButton"; import { useZapsQuery } from "@/hooks/nostrQueries/zaps/useZapsQuery"; +import { nip19 } from "nostr-tools"; import { getTotalFromZaps } from "@/utils/lightning"; import dynamic from "next/dynamic"; +import { Divider } from "primereact/divider"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -16,6 +19,7 @@ const MDDisplay = dynamic( const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { const [zapAmount, setZapAmount] = useState(0); + const [nAddress, setNAddress] = useState(null); const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" }); const { returnImageProxy } = useImageProxy(); @@ -25,6 +29,15 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { setZapAmount(total); }, [zaps, zapsLoading, zapsError, lesson]); + useEffect(() => { + const addr = nip19.naddrEncode({ + pubkey: lesson.pubkey, + kind: lesson.kind, + identifier: lesson.d, + }); + setNAddress(addr); + }, [lesson]); + const renderContent = () => { if (isPaid && decryptionPerformed) { return ( @@ -70,6 +83,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { return (
{renderContent()} +
@@ -106,6 +120,15 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {

+ { + window.open(`https://nostr.com/${nAddress}`, '_blank'); + }} + />
{lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( diff --git a/src/components/sidebar/Sidebar.js b/src/components/sidebar/Sidebar.js index 3b3842d..713468c 100644 --- a/src/components/sidebar/Sidebar.js +++ b/src/components/sidebar/Sidebar.js @@ -1,16 +1,22 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Accordion, AccordionTab } from 'primereact/accordion'; import { useRouter } from 'next/router'; import { useSession, signOut } from 'next-auth/react'; import { useIsAdmin } from '@/hooks/useIsAdmin'; +import { nip19 } from 'nostr-tools'; +import { useToast } from '@/hooks/useToast'; +import { useNDKContext } from '@/context/NDKContext'; import 'primeicons/primeicons.css'; import styles from "./sidebar.module.css"; import { Divider } from 'primereact/divider'; -const Sidebar = () => { +const Sidebar = ({ course = false }) => { const [isExpanded, setIsExpanded] = useState(true); const { isAdmin } = useIsAdmin(); + const [lessons, setLessons] = useState([]); const router = useRouter(); + const { showToast } = useToast(); + const { ndk, addSigner } = useNDKContext(); // Helper function to determine if the path matches the current route const isActive = (path) => { @@ -30,10 +36,84 @@ const Sidebar = () => { setIsExpanded(!isExpanded); }; + useEffect(() => { + if (router.isReady) { + const { slug } = router.query; + + if (slug) { + const { data } = nip19.decode(slug) + + if (!data) { + showToast('error', 'Error', 'Course not found'); + return; + } + + const id = data?.identifier; + const fetchCourse = async (id) => { + try { + await ndk.connect(); + + const filter = { + ids: [id] + } + + const event = await ndk.fetchEvent(filter); + + if (event) { + // all a tags are lessons + const lessons = event.tags.filter(tag => tag[0] === 'a'); + const uniqueLessons = [...new Set(lessons.map(lesson => lesson[1]))]; + setLessons(uniqueLessons); + } + } catch (error) { + console.error('Error fetching event:', error); + } + }; + if (ndk && id) { + fetchCourse(id); + } + } + } + }, [router.isReady, router.query, ndk, course]); + + const scrollToLesson = useCallback((index) => { + const lessonElement = document.getElementById(`lesson-${index}`); + if (lessonElement) { + lessonElement.scrollIntoView({ behavior: 'smooth' }); + } + }, []); + + useEffect(() => { + if (router.isReady && router.query.active) { + const activeIndex = parseInt(router.query.active); + scrollToLesson(activeIndex); + } + }, [router.isReady, router.query.active, scrollToLesson]); + return (
- {isExpanded ? ( + {course && lessons.length > 0 && ( +
+
router.push('/')} className={"w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg"}> +

Home

+
+ {lessons.map((lesson, index) => ( + console.log(lesson), +
{ + router.push(`/course/${router?.query?.slug}?active=${index}`, undefined, { shallow: true }); + scrollToLesson(index); + }} + className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive(`/course/${router?.query?.slug}?active=${index}`) ? 'bg-gray-700' : ''}`} + > +

Lesson {index + 1}

+
+ ))} +
+ )} + {isExpanded && !course ? (
router.push('/')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/') ? 'bg-gray-700' : ''}`}>

Home

@@ -97,13 +177,15 @@ const Sidebar = () => {
) : ( // Collapsed sidebar content (icons only) -
- router.push('/')} /> - router.push('/content')} /> - router.push('/create')} /> - session ? router.push('/profile?tab=subscribe') : router.push('/auth/signin')} /> - router.push('/feed')} /> -
+ !course && ( +
+ router.push('/')} /> + router.push('/content')} /> + router.push('/create')} /> + session ? router.push('/profile?tab=subscribe') : router.push('/auth/signin')} /> + router.push('/feed')} /> +
+ ) )}
diff --git a/src/pages/_app.js b/src/pages/_app.js index a9a12d7..f645c5b 100644 --- a/src/pages/_app.js +++ b/src/pages/_app.js @@ -12,6 +12,7 @@ import 'primeicons/primeicons.css'; import "@uiw/react-md-editor/markdown-editor.css"; import "@uiw/react-markdown-preview/markdown.css"; import Sidebar from '@/components/sidebar/Sidebar'; +import { useRouter } from 'next/router'; import { NDKProvider } from '@/context/NDKContext'; import { QueryClient, @@ -24,6 +25,13 @@ const queryClient = new QueryClient() export default function MyApp({ Component, pageProps: { session, ...pageProps } }) { + const [isCourseView, setIsCourseView] = useState(false); + const router = useRouter(); + + useEffect(() => { + setIsCourseView(router.pathname.includes('course')); + }, [router.pathname]); + // const [sidebarExpanded, setSidebarExpanded] = useState(true); // useEffect(() => { @@ -48,7 +56,7 @@ export default function MyApp({
- +
diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index b311d4d..7218a4f 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -1,127 +1,96 @@ import React, { useEffect, useState, useCallback } from "react"; import { useRouter } from "next/router"; import { parseCourseEvent, parseEvent, findKind0Fields } from "@/utils/nostr"; -import CourseDetails from "@/components/content/courses/CourseDetails"; +import CourseDetailsNew from "@/components/content/courses/CourseDetailsNew"; import VideoLesson from "@/components/content/courses/VideoLesson"; import DocumentLesson from "@/components/content/courses/DocumentLesson"; -import CourseDetailsNew from "@/components/content/courses/CourseDetailsNew"; -import { Divider } from "primereact/divider"; -import dynamic from 'next/dynamic'; import { useNDKContext } from "@/context/NDKContext"; import { useToast } from '@/hooks/useToast'; import { useSession } from 'next-auth/react'; -import { nip04 } from 'nostr-tools'; +import { nip04, nip19 } from 'nostr-tools'; import { ProgressSpinner } from 'primereact/progressspinner'; +import { Accordion, AccordionTab } from 'primereact/accordion'; +import dynamic from 'next/dynamic'; -const MDDisplay = dynamic( - () => import("@uiw/react-markdown-preview"), - { - ssr: false, - } -); +const MDDisplay = dynamic(() => import("@uiw/react-markdown-preview"), { ssr: false }); -const Course = () => { +const useCourseData = (ndk, fetchAuthor, router) => { const [course, setCourse] = useState(null); const [lessonIds, setLessonIds] = useState([]); - const [lessons, setLessons] = useState([]); - const [paidCourse, setPaidCourse] = useState(false); - const [decryptionPerformed, setDecryptionPerformed] = useState(false); - const [loading, setLoading] = useState(true); - const router = useRouter(); - const {ndk, addSigner} = useNDKContext(); - const { data: session, update } = useSession(); - const { showToast } = useToast(); - - const privkey = process.env.NEXT_PUBLIC_APP_PRIV_KEY; - const pubkey = process.env.NEXT_PUBLIC_APP_PUBLIC_KEY; - - const fetchAuthor = useCallback(async (pubkey) => { - const author = await ndk.getUser({ pubkey }); - const profile = await author.fetchProfile(); - const fields = await findKind0Fields(profile); - if (fields) { - return fields; - } - }, [ndk]); useEffect(() => { if (router.isReady) { const { slug } = router.query; - - const fetchCourse = async (slug) => { + const { data } = nip19.decode(slug); + if (!data) { + showToast('error', 'Error', 'Course not found'); + return; + } + const id = data?.identifier; + const fetchCourse = async (id) => { try { await ndk.connect(); - - const filter = { - ids: [slug] - } - + const filter = { ids: [id] }; const event = await ndk.fetchEvent(filter); - if (event) { const author = await fetchAuthor(event.pubkey); const aTags = event.tags.filter(tag => tag[0] === 'a'); const lessonIds = aTags.map(tag => tag[1].split(':')[2]); setLessonIds(lessonIds); - const parsedCourse = { - ...parseCourseEvent(event), - author - }; + const parsedCourse = { ...parseCourseEvent(event), author }; setCourse(parsedCourse); } } catch (error) { console.error('Error fetching event:', error); } }; - if (ndk) { - fetchCourse(slug); + if (ndk && id) { + fetchCourse(id); } } }, [router.isReady, router.query, ndk, fetchAuthor]); + return { course, lessonIds }; +}; + +const useLessons = (ndk, fetchAuthor, lessonIds) => { + const [lessons, setLessons] = useState([]); + const [uniqueLessons, setUniqueLessons] = useState([]); + useEffect(() => { if (lessonIds.length > 0) { - const fetchLesson = async (lessonId) => { try { await ndk.connect(); - - const filter = { - "#d": [lessonId] - } - + const filter = { "#d": [lessonId] }; const event = await ndk.fetchEvent(filter); - if (event) { const author = await fetchAuthor(event.pubkey); - const parsedLesson = { - ...parseEvent(event), - author - }; + const parsedLesson = { ...parseEvent(event), author }; setLessons(prev => [...prev, parsedLesson]); } } catch (error) { console.error('Error fetching event:', error); } }; - lessonIds.forEach(lessonId => fetchLesson(lessonId)); } }, [lessonIds, ndk, fetchAuthor]); useEffect(() => { - console.log('lessons', lessons); + const uniqueLessonSet = new Set(lessons.map(JSON.stringify)); + const newUniqueLessons = Array.from(uniqueLessonSet).map(JSON.parse); + setUniqueLessons(newUniqueLessons); }, [lessons]); - useEffect(() => { - console.log('lessonIds', lessonIds); - }, [lessonIds]); + return { lessons, uniqueLessons, setLessons }; +}; - useEffect(() => { - if (course?.price && course?.price > 0) { - setPaidCourse(true); - } - }, [course]); +const useDecryption = (session, paidCourse, course, lessons, setLessons) => { + const [decryptionPerformed, setDecryptionPerformed] = useState(false); + const [loading, setLoading] = useState(true); + const privkey = process.env.NEXT_PUBLIC_APP_PRIV_KEY; + const pubkey = process.env.NEXT_PUBLIC_APP_PUBLIC_KEY; useEffect(() => { const decryptContent = async () => { @@ -132,8 +101,6 @@ const Course = () => { session.user?.role?.subscribed || session.user?.pubkey === course?.pubkey; - console.log('canAccess', canAccess); - if (canAccess && lessons.length > 0) { try { const decryptedLessons = await Promise.all(lessons.map(async (lesson) => { @@ -151,13 +118,57 @@ const Course = () => { setLoading(false); } decryptContent(); - }, [session, paidCourse, course, lessons, privkey, pubkey, decryptionPerformed]); + }, [session, paidCourse, course, lessons, privkey, pubkey, decryptionPerformed, setLessons]); + + return { decryptionPerformed, loading }; +}; + +const Course = () => { + const router = useRouter(); + const { ndk, addSigner } = useNDKContext(); + const { data: session, update } = useSession(); + const { showToast } = useToast(); + const [paidCourse, setPaidCourse] = useState(false); + const [expandedIndex, setExpandedIndex] = useState(null); + + const fetchAuthor = useCallback(async (pubkey) => { + const author = await ndk.getUser({ pubkey }); + const profile = await author.fetchProfile(); + const fields = await findKind0Fields(profile); + return fields; + }, [ndk]); + + const { course, lessonIds } = useCourseData(ndk, fetchAuthor, router); + const { lessons, uniqueLessons, setLessons } = useLessons(ndk, fetchAuthor, lessonIds); + const { decryptionPerformed, loading } = useDecryption(session, paidCourse, course, lessons, setLessons); useEffect(() => { - if (course && lessons.length > 0 && (!paidCourse || decryptionPerformed)) { - setLoading(false); + if (course?.price && course?.price > 0) { + setPaidCourse(true); } - }, [course, lessons, paidCourse, decryptionPerformed]); + }, [course]); + + useEffect(() => { + if (router.isReady) { + const { active } = router.query; + if (active !== undefined) { + setExpandedIndex(parseInt(active, 10)); + } else { + setExpandedIndex(null); + } + } + }, [router.isReady, router.query]); + + const handleAccordionChange = (e) => { + const newIndex = e.index === expandedIndex ? null : e.index; + setExpandedIndex(newIndex); + + if (newIndex !== null) { + router.push(`/course/${router.query.slug}?active=${newIndex}`, undefined, { shallow: true }); + } else { + router.push(`/course/${router.query.slug}`, undefined, { shallow: true }); + } + }; const handlePaymentSuccess = async (response) => { if (response && response?.preimage) { @@ -187,18 +198,41 @@ const Course = () => { - {lessons.length > 0 && lessons.map((lesson, index) => ( -
-

Lesson {index + 1}

- - {lesson.type === 'workshop' ? : } -
- ))} + + {uniqueLessons.length > 0 && uniqueLessons.map((lesson, index) => ( + + {`Lesson ${index + 1}: ${lesson.title}`} +
+ } + > +
+ {lesson.type === 'workshop' ? + : + + } +
+ + ))} +
{course?.content && }