From cf1f1d73c3785ab225a3db8f7f544cfe6a186a7b Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Thu, 12 Sep 2024 17:39:47 -0500 Subject: [PATCH] New content templates and content details components --- .../bitcoinConnect/ResourcePaymentButton.js | 32 ++-- .../content/carousels/GenericCarousel.js | 56 +++--- .../carousels/newTemplates/CourseTemplate.js | 10 +- .../newTemplates/DocumentTemplate.js | 8 +- .../carousels/newTemplates/VideoTemplate.js | 8 +- .../content/courses/CourseDetailsNew.js | 159 ++++++++++++++++ .../content/courses/DocumentLesson.js | 117 ++++++++++++ src/components/content/courses/VideoLesson.js | 130 +++++++++++++ .../content/documents/DocumentDetails.js | 171 +++++++++++++++++ .../content/resources/ResourceDetails.js | 112 ------------ src/components/content/videos/VideoDetails.js | 173 ++++++++++++++++++ src/components/navbar/Navbar.js | 2 +- src/pages/_app.js | 2 +- src/pages/content/index.js | 6 +- src/pages/course/[slug]/index.js | 13 +- src/pages/details/[slug]/index.js | 106 ++++------- 16 files changed, 865 insertions(+), 240 deletions(-) create mode 100644 src/components/content/courses/CourseDetailsNew.js create mode 100644 src/components/content/courses/DocumentLesson.js create mode 100644 src/components/content/courses/VideoLesson.js create mode 100644 src/components/content/documents/DocumentDetails.js delete mode 100644 src/components/content/resources/ResourceDetails.js create mode 100644 src/components/content/videos/VideoDetails.js diff --git a/src/components/bitcoinConnect/ResourcePaymentButton.js b/src/components/bitcoinConnect/ResourcePaymentButton.js index 635fb89..48de9d6 100644 --- a/src/components/bitcoinConnect/ResourcePaymentButton.js +++ b/src/components/bitcoinConnect/ResourcePaymentButton.js @@ -5,6 +5,7 @@ import { initializeBitcoinConnect } from './BitcoinConnect'; import { LightningAddress } from '@getalby/lightning-tools'; import { useToast } from '@/hooks/useToast'; import { useSession } from 'next-auth/react'; +import { ProgressSpinner } from 'primereact/progressspinner'; import axios from 'axios'; import GenericButton from '@/components/buttons/GenericButton'; @@ -73,17 +74,26 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource return ( <> - setDialogVisible(true)} - disabled={!invoice} - severity='primary' - rounded - className="text-[#f8f8ff] text-sm" - /> - setDialogVisible(true)} + disabled={!invoice} + severity='primary' + rounded + className="text-[#f8f8ff] text-sm" + /> + ) : ( + + )} + setDialogVisible(false)} header="Make Payment" style={{ width: '50vw' }} diff --git a/src/components/content/carousels/GenericCarousel.js b/src/components/content/carousels/GenericCarousel.js index 383217e..2638511 100644 --- a/src/components/content/carousels/GenericCarousel.js +++ b/src/components/content/carousels/GenericCarousel.js @@ -1,9 +1,10 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Carousel } from 'primereact/carousel'; -import ResourceTemplate from '@/components/content/carousels/templates/ResourceTemplate'; -import CourseTemplate from '@/components/content/carousels/templates/CourseTemplate'; -import WorkshopTemplate from '@/components/content/carousels/templates/WorkshopTemplate'; import TemplateSkeleton from '@/components/content/carousels/skeletons/TemplateSkeleton'; +import { VideoTemplate } from '@/components/content/carousels/newTemplates/VideoTemplate'; +import { DocumentTemplate } from '@/components/content/carousels/newTemplates/DocumentTemplate'; +import { CourseTemplate } from '@/components/content/carousels/newTemplates/CourseTemplate'; +import debounce from 'lodash/debounce'; const responsiveOptions = [ { @@ -23,32 +24,37 @@ const responsiveOptions = [ export default function GenericCarousel({items, selectedTopic, title}) { const [carousels, setCarousels] = useState([]); + const memoizedItems = useMemo(() => items, [items]); + useEffect(() => { - const handleResize = () => { - const width = window.innerWidth; - let itemsPerCarousel = 3; + console.log("carousel update", carousels); + }, [carousels]); - if (width <= 1462) { - itemsPerCarousel = 2; - } - if (width <= 575) { - itemsPerCarousel = 1; - } + const getItemsPerCarousel = useCallback(() => { + const width = window.innerWidth; + if (width <= 575) return 1; + if (width <= 1462) return 2; + return 3; + }, []); - const newCarousels = []; - for (let i = 0; i < items.length; i += itemsPerCarousel) { - newCarousels.push(items.slice(i, i + itemsPerCarousel)); - } - setCarousels(newCarousels); - }; + const updateCarousels = useCallback(() => { + const itemsPerCarousel = getItemsPerCarousel(); + const newCarousels = []; + for (let i = 0; i < memoizedItems.length; i += itemsPerCarousel) { + newCarousels.push(memoizedItems.slice(i, i + itemsPerCarousel)); + } + setCarousels(newCarousels); + }, [memoizedItems, getItemsPerCarousel]); - handleResize(); - window.addEventListener('resize', handleResize); + useEffect(() => { + updateCarousels(); + const debouncedHandleResize = debounce(updateCarousels, 250); + window.addEventListener('resize', debouncedHandleResize); return () => { - window.removeEventListener('resize', handleResize); + window.removeEventListener('resize', debouncedHandleResize); }; - }, [items]); + }, [updateCarousels, memoizedItems]); return ( <> @@ -59,9 +65,9 @@ export default function GenericCarousel({items, selectedTopic, title}) { itemTemplate={(item) => { if (carouselItems.length > 0) { if (item.type === 'resource') { - return ; + return ; } else if (item.type === 'workshop') { - return ; + return ; } else if (item.type === 'course') { return ; } diff --git a/src/components/content/carousels/newTemplates/CourseTemplate.js b/src/components/content/carousels/newTemplates/CourseTemplate.js index d3229e2..93cdcdb 100644 --- a/src/components/content/carousels/newTemplates/CourseTemplate.js +++ b/src/components/content/carousels/newTemplates/CourseTemplate.js @@ -7,7 +7,7 @@ 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/navigation"; +import { useRouter } from "next/router"; import { formatTimestampToHowLongAgo } from "@/utils/time"; import GenericButton from "@/components/buttons/GenericButton"; @@ -42,17 +42,17 @@ export function CourseTemplate({ course }) { alt="Course background" quality={100} layout="fill" - className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" + className={`${router.pathname === "/content" ? "w-full h-full object-cover" : "w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"}`} />
- +
- {course.name || course.title} + {course.name || course.title}
@@ -82,7 +82,7 @@ export function CourseTemplate({ course }) { ) : ( formatTimestampToHowLongAgo(course.created_at) )}

- router.push(`/details/${course.id}`)} size="small" label="Start Learning" icon="pi pi-chevron-right" iconPos="right" outlined className="items-center py-2" /> + router.push(`/course/${course.id}`)} size="small" label="Start Learning" icon="pi pi-chevron-right" iconPos="right" outlined className="items-center py-2" /> ) diff --git a/src/components/content/carousels/newTemplates/DocumentTemplate.js b/src/components/content/carousels/newTemplates/DocumentTemplate.js index 76c951c..c96a186 100644 --- a/src/components/content/carousels/newTemplates/DocumentTemplate.js +++ b/src/components/content/carousels/newTemplates/DocumentTemplate.js @@ -6,7 +6,7 @@ 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/navigation"; +import { useRouter } from "next/router"; import { formatTimestampToHowLongAgo } from "@/utils/time"; import { Tag } from "primereact/tag"; import GenericButton from "@/components/buttons/GenericButton"; @@ -34,17 +34,17 @@ export function DocumentTemplate({ document }) { alt="Document background" quality={100} layout="fill" - className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" + className={`${router.pathname === "/content" ? "w-full h-full object-cover" : "w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"}`} />
- +
- {document.title} + {document.title}
diff --git a/src/components/content/carousels/newTemplates/VideoTemplate.js b/src/components/content/carousels/newTemplates/VideoTemplate.js index d414eae..6fb6e90 100644 --- a/src/components/content/carousels/newTemplates/VideoTemplate.js +++ b/src/components/content/carousels/newTemplates/VideoTemplate.js @@ -6,7 +6,7 @@ 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/navigation"; +import { useRouter } from "next/router"; import { formatTimestampToHowLongAgo } from "@/utils/time"; import { Tag } from "primereact/tag"; import GenericButton from "@/components/buttons/GenericButton"; @@ -34,17 +34,17 @@ export function VideoTemplate({ video }) { alt="Video thumbnail" quality={100} layout="fill" - className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" + className={`${router.pathname === "/content" ? "w-full h-full object-cover" : "w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"}`} />
- +
- {video.title} + {video.title}
diff --git a/src/components/content/courses/CourseDetailsNew.js b/src/components/content/courses/CourseDetailsNew.js new file mode 100644 index 0000000..716fff0 --- /dev/null +++ b/src/components/content/courses/CourseDetailsNew.js @@ -0,0 +1,159 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import axios from 'axios'; +import { useToast } from "@/hooks/useToast"; +import { Tag } from 'primereact/tag'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; +import CoursePaymentButton from "@/components/bitcoinConnect/CoursePaymentButton"; +import ZapDisplay from '@/components/zaps/ZapDisplay'; +import GenericButton from '@/components/buttons/GenericButton'; +import { 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'; + +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 router = useRouter(); + const { returnImageProxy } = useImageProxy(); + const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: processedEvent }); + const { data: session, status } = useSession(); + const { showToast } = useToast(); + const windowWidth = useWindowWidth(); + const { ndk } = useNDKContext(); + + const fetchAuthor = useCallback(async (pubkey) => { + if (!pubkey) return; + const author = await ndk.getUser({ pubkey }); + const profile = await author.fetchProfile(); + const fields = await findKind0Fields(profile); + if (fields) { + setAuthor(fields); + } + }, [ndk]); + + useEffect(() => { + if (processedEvent) { + fetchAuthor(processedEvent.pubkey); + } + }, [fetchAuthor, processedEvent]); + + useEffect(() => { + if (zaps.length > 0) { + const total = getTotalFromZaps(zaps, processedEvent); + setZapAmount(total); + } + }, [zaps, processedEvent]); + + const handleDelete = async () => { + try { + const response = await axios.delete(`/api/courses/${processedEvent.d}`); + if (response.status === 204) { + showToast('success', 'Success', 'Course deleted successfully.'); + router.push('/'); + } + } catch (error) { + showToast('error', 'Error', 'Failed to delete course. Please try again.'); + } + } + + const renderPaymentMessage = () => { + if (session?.user && session.user?.role?.subscribed && decryptionPerformed) { + return + } + + if (paidCourse && decryptionPerformed && author && processedEvent?.pubkey !== session?.user?.pubkey && !session?.user?.role?.subscribed) { + return + } + + if (paidCourse && author && processedEvent?.pubkey === session?.user?.pubkey) { + return + } + + if (paidCourse && !decryptionPerformed) { + return ( + + ); + } + + return null; + }; + + if (!processedEvent || !author) { + return
Loading...
; + } + + return ( +
+
+ course image +
+
+
+ router.push('/')} /> +
+
+

{processedEvent.name}

+
+ {processedEvent.topics && processedEvent.topics.length > 0 && ( + processedEvent.topics.map((topic, index) => ( + + )) + )} +
+
+

{processedEvent.description}

+ +
+ {renderPaymentMessage()} + {processedEvent?.pubkey === session?.user?.pubkey && ( +
+ router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined /> + +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js new file mode 100644 index 0000000..2c25e88 --- /dev/null +++ b/src/components/content/courses/DocumentLesson.js @@ -0,0 +1,117 @@ +import React, { useEffect, useState } from "react"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; +import { useImageProxy } from "@/hooks/useImageProxy"; +import { useZapsQuery } from "@/hooks/nostrQueries/zaps/useZapsQuery"; +import { getTotalFromZaps } from "@/utils/lightning"; +import dynamic from "next/dynamic"; + +const MDDisplay = dynamic( + () => import("@uiw/react-markdown-preview"), + { + ssr: false, + } +); + +const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { + const [zapAmount, setZapAmount] = useState(0); + const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" }); + const { returnImageProxy } = useImageProxy(); + + useEffect(() => { + if (!zaps || zapsLoading || zapsError) return; + const total = getTotalFromZaps(zaps, lesson); + setZapAmount(total); + }, [zaps, zapsLoading, zapsError, lesson]); + + const renderContent = () => { + if (isPaid && decryptionPerformed) { + return ; + } + if (isPaid && !decryptionPerformed) { + return ( +
+
+ +
+

+ This content is paid and needs to be purchased before viewing. +

+
+ ); + } + if (lesson?.content) { + return ; + } + return null; + } + + return ( +
+
+ lesson background image +
+
+
+
+
+

{lesson.title}

+
+ {lesson.topics && lesson.topics.length > 0 && ( + lesson.topics.map((topic, index) => ( + + )) + )} +
+
+

{lesson.summary}

+ +
+
+ {renderContent()} + {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( +
+

External links:

+ +
+ )} +
+ ) +} + +export default DocumentLesson; \ No newline at end of file diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js new file mode 100644 index 0000000..39b9519 --- /dev/null +++ b/src/components/content/courses/VideoLesson.js @@ -0,0 +1,130 @@ +import React, { useEffect, useState } from "react"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; +import { useImageProxy } from "@/hooks/useImageProxy"; +import { useZapsQuery } from "@/hooks/nostrQueries/zaps/useZapsQuery"; +import { getTotalFromZaps } from "@/utils/lightning"; +import dynamic from "next/dynamic"; + +const MDDisplay = dynamic( + () => import("@uiw/react-markdown-preview"), + { + ssr: false, + } +); + +const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { + const [zapAmount, setZapAmount] = useState(0); + const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" }); + const { returnImageProxy } = useImageProxy(); + + useEffect(() => { + if (!zaps || zapsLoading || zapsError) return; + const total = getTotalFromZaps(zaps, lesson); + setZapAmount(total); + }, [zaps, zapsLoading, zapsError, lesson]); + + const renderContent = () => { + if (isPaid && decryptionPerformed) { + return ( + <> +
+ {/* Add your video player component here */} + +
+ + + ); + } + if (isPaid && !decryptionPerformed) { + return ( +
+
+
+
+ +
+

+ This content is paid and needs to be purchased before viewing. +

+
+ ); + } + if (lesson?.content) { + return ; + } + return null; + } + + return ( +
+ {renderContent()} +
+
+
+

{lesson.title}

+ {lesson.topics && lesson.topics.length > 0 && ( + lesson.topics.map((topic, index) => ( + + )) + )} +
+
+

{lesson.summary}

+ +
+
+ + {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( +
+

External links:

+ +
+ )} +
+
+ ) +} + +export default VideoLesson; \ No newline at end of file diff --git a/src/components/content/documents/DocumentDetails.js b/src/components/content/documents/DocumentDetails.js new file mode 100644 index 0000000..89c7e5f --- /dev/null +++ b/src/components/content/documents/DocumentDetails.js @@ -0,0 +1,171 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; +import { useToast } from "@/hooks/useToast"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; +import GenericButton from "@/components/buttons/GenericButton"; +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"; + +const MDDisplay = dynamic( + () => import("@uiw/react-markdown-preview"), + { + ssr: false, + } +); + +const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS; + +const DocumentDetails = ({ processedEvent, topics, title, summary, image, price, author, paidResource, decryptedContent, handlePaymentSuccess, handlePaymentError, authorView }) => { + const [zapAmount, setZapAmount] = useState(0); + const router = useRouter(); + const { returnImageProxy } = useImageProxy(); + const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: processedEvent }); + const { data: session, status } = useSession(); + const { showToast } = useToast(); + const windowWidth = useWindowWidth(); + + useEffect(() => { + if (zaps.length > 0) { + const total = getTotalFromZaps(zaps, processedEvent); + setZapAmount(total); + } + }, [zaps, processedEvent]); + + const handleDelete = async () => { + try { + const response = await axios.delete(`/api/resources/${processedEvent.d}`); + if (response.status === 204) { + showToast('success', 'Success', 'Resource deleted successfully.'); + router.push('/'); + } + } catch (error) { + if (error.response && error.response.data && error.response.data.error.includes("Invalid `prisma.resource.delete()`")) { + showToast('error', 'Error', 'Resource cannot be deleted because it is part of a course, delete the course first.'); + } + else if (error.response && error.response.data && error.response.data.error) { + showToast('error', 'Error', error.response.data.error); + } else { + showToast('error', 'Error', 'Failed to delete resource. Please try again.'); + } + } + } + + const renderPaymentMessage = () => { + if (session?.user && session.user?.role?.subscribed && decryptedContent) { + return + } + + if (paidResource && decryptedContent && author && processedEvent?.pubkey !== session?.user?.pubkey && !session?.user?.role?.subscribed) { + return + } + + if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { + return + } + + return null; + }; + + const renderContent = () => { + if (decryptedContent) { + return ; + } + if (paidResource && !decryptedContent) { + return ( +
+
+ +
+

+ This content is paid and needs to be purchased before viewing. +

+
+ +
+
+ ); + } + if (processedEvent?.content) { + return ; + } + return null; + } + + return ( +
+
+ background image +
+
+
+
+
+

{title}

+
+ {topics && topics.length > 0 && ( + topics.map((topic, index) => ( + + )) + )} +
+
+

{summary}

+
+
+ avatar image +

+ By{' '} + + {author?.username} + +

+
+ +
+
+
+ {renderContent()} + +
+ {renderPaymentMessage()} + {authorView && ( +
+ router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined /> + +
+ )} +
+
+ ) +} + +export default DocumentDetails; diff --git a/src/components/content/resources/ResourceDetails.js b/src/components/content/resources/ResourceDetails.js deleted file mode 100644 index e39af99..0000000 --- a/src/components/content/resources/ResourceDetails.js +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { Tag } from "primereact/tag"; -import Image from "next/image"; -import { useRouter } from "next/router"; -import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton"; -import ZapDisplay from "@/components/zaps/ZapDisplay"; -import GenericButton from "@/components/buttons/GenericButton"; -import { useImageProxy } from "@/hooks/useImageProxy"; -import { useZapsSubscription } from "@/hooks/nostrQueries/zaps/useZapsSubscription"; -import { getTotalFromZaps } from "@/utils/lightning"; -import { useSession } from "next-auth/react"; - -const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS; - -const ResourceDetails = ({processedEvent, topics, title, summary, image, price, author, paidResource, decryptedContent, handlePaymentSuccess, handlePaymentError}) => { - const [zapAmount, setZapAmount] = useState(0); - - const router = useRouter(); - const { returnImageProxy } = useImageProxy(); - const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: processedEvent }); - const { data: session, status } = useSession(); - - useEffect(() => { - if (zaps.length > 0) { - const total = getTotalFromZaps(zaps, processedEvent); - setZapAmount(total); - } - }, [zaps, processedEvent]); - - const renderPaymentMessage = () => { - if (session?.user && session.user?.role?.subscribed && decryptedContent) { - return - } - - if (paidResource && decryptedContent && author && processedEvent?.pubkey !== session?.user?.pubkey && !session?.user?.role?.subscribed) { - return - } - - if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { - return - } - - return null; - }; - - return ( -
- router.push('/')} /> -
-
-
- {topics && topics.length > 0 && ( - topics.map((topic, index) => ( - - )) - ) - } -
-

{title}

-

{summary}

-
- avatar image -

- Created by{' '} - - {author?.username} - -

-
-
-
- {image && ( -
- resource thumbnail -
- {paidResource && !decryptedContent && } - - {renderPaymentMessage()} - - -
-
- )} -
-
-
- ) -} - -export default ResourceDetails; \ No newline at end of file diff --git a/src/components/content/videos/VideoDetails.js b/src/components/content/videos/VideoDetails.js new file mode 100644 index 0000000..f7f10cb --- /dev/null +++ b/src/components/content/videos/VideoDetails.js @@ -0,0 +1,173 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; +import { useToast } from "@/hooks/useToast"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; +import GenericButton from "@/components/buttons/GenericButton"; +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"; + +const MDDisplay = dynamic( + () => import("@uiw/react-markdown-preview"), + { + ssr: false, + } +); + +const lnAddress = process.env.NEXT_PUBLIC_LIGHTNING_ADDRESS; + +const VideoDetails = ({ processedEvent, topics, title, summary, image, price, author, paidResource, decryptedContent, handlePaymentSuccess, handlePaymentError, authorView }) => { + const [zapAmount, setZapAmount] = useState(0); + const router = useRouter(); + const { returnImageProxy } = useImageProxy(); + const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: processedEvent }); + const { data: session, status } = useSession(); + const { showToast } = useToast(); + const windowWidth = useWindowWidth(); + + useEffect(() => { + if (zaps.length > 0) { + const total = getTotalFromZaps(zaps, processedEvent); + setZapAmount(total); + } + }, [zaps, processedEvent]); + + const handleDelete = async () => { + try { + const response = await axios.delete(`/api/resources/${processedEvent.d}`); + if (response.status === 204) { + showToast('success', 'Success', 'Resource deleted successfully.'); + router.push('/'); + } + } catch (error) { + if (error.response && error.response.data && error.response.data.error.includes("Invalid `prisma.resource.delete()`")) { + showToast('error', 'Error', 'Resource cannot be deleted because it is part of a course, delete the course first.'); + } + else if (error.response && error.response.data && error.response.data.error) { + showToast('error', 'Error', error.response.data.error); + } else { + showToast('error', 'Error', 'Failed to delete resource. Please try again.'); + } + } + } + + const renderPaymentMessage = () => { + if (session?.user && session.user?.role?.subscribed && decryptedContent) { + return + } + + if (paidResource && decryptedContent && author && processedEvent?.pubkey !== session?.user?.pubkey && !session?.user?.role?.subscribed) { + return + } + + if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { + return + } + + return null; + }; + + const renderContent = () => { + if (decryptedContent) { + return ; + } + if (paidResource && !decryptedContent) { + return ( +
+
+
+
+ +
+

+ This content is paid and needs to be purchased before viewing. +

+
+ +
+
+ ); + } + if (processedEvent?.content) { + return ; + } + return null; + } + + return ( +
+ {renderContent()} +
+
+
+

{title}

+ {topics && topics.length > 0 && ( + topics.map((topic, index) => ( + + )) + ) + } +
+
+

{summary}

+ +
+
+
+
+
+ avatar image +

+ Created by{' '} + + {author?.username} + +

+
+ {authorView && ( +
+ router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined /> + +
+ )} +
+
+ {renderPaymentMessage()} +
+
+
+
+ ) +} + +export default VideoDetails; \ No newline at end of file diff --git a/src/components/navbar/Navbar.js b/src/components/navbar/Navbar.js index 94c86f9..9cf1a8a 100644 --- a/src/components/navbar/Navbar.js +++ b/src/components/navbar/Navbar.js @@ -32,7 +32,7 @@ const Navbar = () => { return ( <> -
+
-
+
diff --git a/src/pages/content/index.js b/src/pages/content/index.js index 76b8918..570d670 100644 --- a/src/pages/content/index.js +++ b/src/pages/content/index.js @@ -80,6 +80,8 @@ const ContentPage = () => { const [selectedTopic, setSelectedTopic] = useState('All') const [filteredContent, setFilteredContent] = useState([]); + const memoizedFilteredContent = useMemo(() => filteredContent, [filteredContent]); + useEffect(() => { const tag = router.query.tag; if (tag) { @@ -150,8 +152,8 @@ const ContentPage = () => { const renderCarousels = () => { return ( { return ( <> - { handlePaymentError={handlePaymentError} /> {lessons.length > 0 && lessons.map((lesson, index) => ( - +
+

Lesson {index + 1}

+ + {lesson.type === 'workshop' ? : } +
))}
{course?.content && } diff --git a/src/pages/details/[slug]/index.js b/src/pages/details/[slug]/index.js index 81b0797..3812dd6 100644 --- a/src/pages/details/[slug]/index.js +++ b/src/pages/details/[slug]/index.js @@ -1,25 +1,16 @@ import React, { useEffect, useState } from 'react'; -import axios from 'axios'; import { useRouter } from 'next/router'; import { parseEvent, findKind0Fields } from '@/utils/nostr'; -import GenericButton from '@/components/buttons/GenericButton'; import { nip19, nip04 } from 'nostr-tools'; import { useSession } from 'next-auth/react'; -import dynamic from 'next/dynamic'; import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; import { useToast } from '@/hooks/useToast'; import { useNDKContext } from '@/context/NDKContext'; -import ResourceDetails from '@/components/content/resources/ResourceDetails'; +import VideoDetails from '@/components/content/videos/VideoDetails'; +import DocumentDetails from '@/components/content/documents/DocumentDetails'; import { ProgressSpinner } from 'primereact/progressspinner'; import 'primeicons/primeicons.css'; -const MDDisplay = dynamic( - () => import("@uiw/react-markdown-preview"), - { - ssr: false, - } -); - const privkey = process.env.NEXT_PUBLIC_APP_PRIV_KEY; const pubkey = process.env.NEXT_PUBLIC_APP_PUBLIC_KEY; @@ -55,22 +46,21 @@ export default function Details() { useEffect(() => { const decryptContent = async () => { - if (user && paidResource) { - if (user?.purchased?.length > 0) { - const purchasedResource = user?.purchased.find(purchase => purchase.resourceId === processedEvent.d); - if (purchasedResource) { - console.log("purchasedResource", purchasedResource) - const decryptedContent = await nip04.decrypt(privkey, pubkey, processedEvent.content); - setDecryptedContent(decryptedContent); - } - } else if (user?.role && user?.role.subscribed) { - // decrypt the content + if (paidResource && processedEvent.content) { + // Check if user is subscribed first + if (user?.role?.subscribed) { + const decryptedContent = await nip04.decrypt(privkey, pubkey, processedEvent.content); + setDecryptedContent(decryptedContent); + } + // If not subscribed, check if they have purchased + else if (user?.purchased?.some(purchase => purchase.resourceId === processedEvent.d)) { const decryptedContent = await nip04.decrypt(privkey, pubkey, processedEvent.content); setDecryptedContent(decryptedContent); } + // If neither subscribed nor purchased, decryptedContent remains null } + }; - } decryptContent(); }, [user, paidResource, processedEvent]); @@ -156,6 +146,7 @@ export default function Details() { useEffect(() => { if (event) { const parsedEvent = parseEvent(event); + console.log("parsedEvent", parsedEvent); setProcessedEvent(parsedEvent); } }, [event]); @@ -171,25 +162,6 @@ export default function Details() { } }, [processedEvent]); - const handleDelete = async () => { - try { - const response = await axios.delete(`/api/resources/${processedEvent.d}`); - if (response.status === 204) { - showToast('success', 'Success', 'Resource deleted successfully.'); - router.push('/'); - } - } catch (error) { - if (error.response && error.response.data && error.response.data.error.includes("Invalid `prisma.resource.delete()`")) { - showToast('error', 'Error', 'Resource cannot be deleted because it is part of a course, delete the course first.'); - } - else if (error.response && error.response.data && error.response.data.error) { - showToast('error', 'Error', error.response.data.error); - } else { - showToast('error', 'Error', 'Failed to delete resource. Please try again.'); - } - } - } - const handlePaymentSuccess = async (response, newResource) => { if (response && response?.preimage) { console.log("newResource", newResource); @@ -204,19 +176,6 @@ export default function Details() { showToast('error', 'Payment Error', `Failed to purchase resource. Please try again. Error: ${error}`); } - const renderContent = () => { - if (decryptedContent) { - return ; - } - if (paidResource && !decryptedContent) { - return

This content is paid and needs to be purchased before viewing.

; - } - if (processedEvent?.content) { - return ; - } - return null; - } - if (loading) { return
@@ -230,9 +189,9 @@ export default function Details() { } return ( -
- {processedEvent && ( - + {processedEvent && processedEvent.type !== "workshop" ? ( + + ) : ( + )} - {authorView && ( -
-
- router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined className="w-auto m-2" /> - -
-
- )} {typeof window !== 'undefined' && nAddress !== null && ( -
+
)} -
- { - processedEvent && processedEvent.content && renderContent() - } -
); }