diff --git a/src/components/content/combined/CombinedDetails.js b/src/components/content/combined/CombinedDetails.js index f28124f..18f8d0e 100644 --- a/src/components/content/combined/CombinedDetails.js +++ b/src/components/content/combined/CombinedDetails.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import axios from "axios"; import { useToast } from "@/hooks/useToast"; import { Tag } from "primereact/tag"; @@ -13,6 +13,8 @@ import { getTotalFromZaps } from "@/utils/lightning"; import { useSession } from "next-auth/react"; import useWindowWidth from "@/hooks/useWindowWidth"; import dynamic from "next/dynamic"; +import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -29,6 +31,62 @@ const CombinedDetails = ({ processedEvent, topics, title, summary, image, price, const { showToast } = useToast(); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; + const menuRef = useRef(null); + const toastRef = useRef(null); + + 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?.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 { + showToast('error', 'Error', 'Failed to delete resource. Please try again.'); + } + } + }; + + const authorMenuItems = [ + { + label: 'Edit', + icon: 'pi pi-pencil', + command: () => router.push(`/details/${processedEvent.id}/edit`) + }, + { + label: 'Delete', + icon: 'pi pi-trash', + command: handleDelete + }, + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + } + ]; + + const userMenuItems = [ + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + } + ]; + + if (course) { + userMenuItems.unshift({ + label: isMobileView ? 'Course' : 'Open Course', + icon: 'pi pi-external-link', + command: () => window.open(`/course/${course}`, '_blank') + }); + } useEffect(() => { if (isLesson) { @@ -49,22 +107,6 @@ const CombinedDetails = ({ processedEvent, topics, title, summary, image, price, } }, [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?.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 { - showToast('error', 'Error', 'Failed to delete resource. Please try again.'); - } - } - }; - const renderPaymentMessage = () => { if (session?.user?.role?.subscribed && decryptedContent) { return ; @@ -124,37 +166,9 @@ const CombinedDetails = ({ processedEvent, topics, title, summary, image, price, return null; }; - const renderAdditionalLinks = () => { - if (processedEvent?.additionalLinks?.length > 0) { - return ( -
-

Additional Links:

- {processedEvent.additionalLinks.map((link, index) => ( -
- - {link} - -
- ))} -
- ); - } - return null; - }; - return (
+
background image

{title}

-
- {topics?.map((topic, index) => ( - - ))} - {isLesson && } -
+ +
+
+ {topics?.map((topic, index) => ( + + ))} + {isLesson && }
{summary?.split('\n').map((line, index) => (

{line}

))} - {renderAdditionalLinks()} -
+
avatar image

- +
+ +
-
- {authorView ? ( -
- {renderPaymentMessage()} -
- router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined /> - - window.open(`https://habla.news/a/${nAddress}`, '_blank')} - /> -
-
- ) : ( -
- {renderPaymentMessage()} -
- {course && ( - window.open(`/course/${course}`, '_blank')} - label={isMobileView ? "Course" : "Open Course"} - tooltip="This is a lesson in a course" - tooltipOptions={{ position: 'top' }} - /> - )} - window.open(`https://habla.news/a/${nAddress}`, '_blank')} - /> -
-
- )} +
+ {renderPaymentMessage()}
{renderContent()} diff --git a/src/components/content/courses/CombinedLesson.js b/src/components/content/courses/CombinedLesson.js index 3428395..f026390 100644 --- a/src/components/content/courses/CombinedLesson.js +++ b/src/components/content/courses/CombinedLesson.js @@ -4,7 +4,6 @@ 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"; @@ -12,6 +11,10 @@ import dynamic from "next/dynamic"; import useWindowWidth from "@/hooks/useWindowWidth"; import appConfig from "@/config/appConfig"; import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson'; +import { Menu } from "primereact/menu"; +import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; +import { useSession } from "next-auth/react"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -26,13 +29,16 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple const [videoDuration, setVideoDuration] = useState(null); const [videoPlayed, setVideoPlayed] = useState(false); const mdDisplayRef = useRef(null); + const menuRef = useRef(null); + const toastRef = useRef(null); const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" }); const { returnImageProxy } = useImageProxy(); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; const isVideo = lesson?.type === 'video'; + const { data: session } = useSession(); - const { isCompleted: videoCompleted, isTracking: videoTracking } = useTrackVideoLesson({ + const { isCompleted: videoCompleted, isTracking: videoTracking, markLessonAsCompleted } = useTrackVideoLesson({ lessonId: lesson?.d, videoDuration, courseId: course?.d, @@ -41,6 +47,61 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple decryptionPerformed }); + const buildMenuItems = () => { + const items = []; + + const hasAccess = session?.user && ( + !isPaid || + decryptionPerformed || + session.user.role?.subscribed + ); + + if (hasAccess) { + items.push({ + label: 'Mark as completed', + icon: 'pi pi-check-circle', + command: async () => { + try { + await markLessonAsCompleted(); + setCompleted(lesson.id); + toastRef.current.show({ + severity: 'success', + summary: 'Success', + detail: 'Lesson marked as completed', + life: 3000 + }); + } catch (error) { + console.error('Failed to mark lesson as completed:', error); + toastRef.current.show({ + severity: 'error', + summary: 'Error', + detail: 'Failed to mark lesson as completed', + life: 3000 + }); + } + } + }); + } + + items.push({ + label: 'Open lesson', + icon: 'pi pi-arrow-up-right', + command: () => { + window.open(`/details/${lesson.id}`, '_blank'); + } + }); + + items.push({ + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + }); + + return items; + }; + useEffect(() => { const handleYouTubeMessage = (event) => { if (event.origin !== "https://www.youtube.com") return; @@ -168,6 +229,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple return (
+ {isVideo ? renderContent() : ( <>
@@ -185,13 +247,18 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple

{lesson.title}

-
- {lesson.topics && lesson.topics.length > 0 && ( - lesson.topics.map((topic, index) => ( - - )) - )} -
+ +
+
+ {lesson.topics && lesson.topics.length > 0 && ( + lesson.topics.map((topic, index) => ( + + )) + )}
{lesson.summary && (
@@ -201,7 +268,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
)}
-
+
avatar image

- -
-
- { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); - }} - /> +
+ +
{!isVideo && } - {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( -
-

External links:

- -
- )} + {!isVideo && renderContent()}
- {!isVideo && renderContent()}
); }; diff --git a/src/components/content/courses/CourseDetails.js b/src/components/content/courses/CourseDetails.js index 82c9c7f..d61b595 100644 --- a/src/components/content/courses/CourseDetails.js +++ b/src/components/content/courses/CourseDetails.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; import axios from 'axios'; import { useToast } from "@/hooks/useToast"; import { Tag } from 'primereact/tag'; @@ -19,6 +19,8 @@ import appConfig from "@/config/appConfig"; import useTrackCourse from '@/hooks/tracking/useTrackCourse'; import WelcomeModal from '@/components/onboarding/WelcomeModal'; import { ProgressSpinner } from 'primereact/progressspinner'; +import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; export default function CourseDetails({ processedEvent, paidCourse, lessons, decryptionPerformed, handlePaymentSuccess, handlePaymentError }) { const [zapAmount, setZapAmount] = useState(0); @@ -32,6 +34,40 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; const { ndk } = useNDKContext(); + const menuRef = useRef(null); + const toastRef = useRef(null); + + 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 menuItems = [ + { + label: processedEvent?.pubkey === session?.user?.pubkey ? 'Edit' : null, + icon: 'pi pi-pencil', + command: () => router.push(`/course/${processedEvent.d}/edit`), + visible: processedEvent?.pubkey === session?.user?.pubkey + }, + { + label: processedEvent?.pubkey === session?.user?.pubkey ? 'Delete' : null, + icon: 'pi pi-trash', + command: handleDelete, + visible: processedEvent?.pubkey === session?.user?.pubkey + }, + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => window.open(`https://nostr.band/${nAddress}`, '_blank') + } + ]; const { isCompleted } = useTrackCourse({ courseId: processedEvent?.d, @@ -74,18 +110,6 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec } }, [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 @@ -101,45 +125,18 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec if (paidCourse && !decryptionPerformed) { return ( - - ); - } - - return null; - }; - - const renderAdditionalLinks = () => { - if (processedEvent?.additionalLinks && processedEvent.additionalLinks.length > 0) { - return ( -
-

Additional Links:

- {processedEvent.additionalLinks.map((link, index) => ( - - ))} +
+
); } + return null; }; @@ -149,7 +146,8 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec return (
- + +
course image}

{processedEvent.name}

-
- {processedEvent.topics && processedEvent.topics.length > 0 && ( - processedEvent.topics.map((topic, index) => ( - - )) - )} -
+ +
+
+ {processedEvent.topics && processedEvent.topics.length > 0 && ( + processedEvent.topics.map((topic, index) => ( + + )) + )}
{processedEvent.description && ( processedEvent.description.split('\n').map((line, index) => ( @@ -179,7 +182,7 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec )) )}
-
+
avatar image

- +
+ +
-
+
{renderPaymentMessage()} - {processedEvent?.pubkey === session?.user?.pubkey ? ( -
- router.push(`/course/${processedEvent.d}/edit`)} label="Edit" severity='warning' outlined /> - - window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={isMobileView ? null : "View Nostr Event"} tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} /> -
- ) : ( -
- window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={isMobileView ? null : "View Nostr Event"} tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} /> -
- )} - {renderAdditionalLinks()}
diff --git a/src/components/content/courses/CourseLesson.js b/src/components/content/courses/CourseLesson.js index 2b04dd8..34ba3a9 100644 --- a/src/components/content/courses/CourseLesson.js +++ b/src/components/content/courses/CourseLesson.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { Tag } from "primereact/tag"; import Image from "next/image"; import { useImageProxy } from "@/hooks/useImageProxy"; @@ -6,6 +6,13 @@ import { getTotalFromZaps } from "@/utils/lightning"; import ZapDisplay from "@/components/zaps/ZapDisplay"; import dynamic from "next/dynamic"; import { useZapsQuery } from "@/hooks/nostrQueries/zaps/useZapsQuery"; +import { Toast } from "primereact/toast"; +import useTrackDocumentLesson from "@/hooks/tracking/useTrackDocumentLesson"; +import useWindowWidth from "@/hooks/useWindowWidth"; +import { nip19 } from "nostr-tools"; +import appConfig from "@/config/appConfig"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; +import { useSession } from "next-auth/react"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -14,10 +21,88 @@ const MDDisplay = dynamic( } ); -const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { +const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => { const [zapAmount, setZapAmount] = useState(0); const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" }); const { returnImageProxy } = useImageProxy(); + const menuRef = useRef(null); + const toastRef = useRef(null); + const windowWidth = useWindowWidth(); + const isMobileView = windowWidth <= 768; + const { data: session } = useSession(); + + const readTime = lesson?.content ? Math.max(30, Math.ceil(lesson.content.length / 20)) : 60; + + const { isCompleted, isTracking, markLessonAsCompleted } = useTrackDocumentLesson({ + lessonId: lesson?.d, + courseId: course?.d, + readTime, + paidCourse: isPaid, + decryptionPerformed + }); + + const buildMenuItems = () => { + const items = []; + + const hasAccess = session?.user && ( + !isPaid || + decryptionPerformed || + session.user.role?.subscribed + ); + + if (hasAccess) { + items.push({ + label: 'Mark as completed', + icon: 'pi pi-check-circle', + command: async () => { + try { + await markLessonAsCompleted(); + setCompleted && setCompleted(lesson.id); + toastRef.current.show({ + severity: 'success', + summary: 'Success', + detail: 'Lesson marked as completed', + life: 3000 + }); + } catch (error) { + console.error('Failed to mark lesson as completed:', error); + toastRef.current.show({ + severity: 'error', + summary: 'Error', + detail: 'Failed to mark lesson as completed', + life: 3000 + }); + } + } + }); + } + + items.push({ + label: 'Open lesson', + icon: 'pi pi-arrow-up-right', + command: () => { + window.open(`/details/${lesson.id}`, '_blank'); + } + }); + + items.push({ + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + if (lesson?.d) { + const addr = nip19.naddrEncode({ + pubkey: lesson.pubkey, + kind: lesson.kind, + identifier: lesson.d, + relays: appConfig.defaultRelayUrls || [] + }); + window.open(`https://habla.news/a/${addr}`, '_blank'); + } + } + }); + + return items; + }; useEffect(() => { if (!zaps || zapsLoading || zapsError) return; @@ -26,6 +111,12 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { setZapAmount(total); }, [zaps, zapsLoading, zapsError, lesson]); + + useEffect(() => { + if (isCompleted && !isTracking && setCompleted) { + setCompleted(lesson.id); + } + }, [isCompleted, isTracking, lesson.id, setCompleted]); const renderContent = () => { if (isPaid && decryptionPerformed) { @@ -42,53 +133,56 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { return (
+
-
+
+

{lesson?.title}

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

{lesson?.title}

-

{lesson?.summary && ( -

- {lesson.summary.split('\n').map((line, index) => ( -

{line}

- ))} -
- )} -

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

External links:

- +
{lesson?.summary && ( +
+ {lesson.summary.split('\n').map((line, index) => ( +

{line}

+ ))}
)} - +
@@ -101,9 +195,6 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { height={194} className="w-[344px] h-[194px] object-cover object-top rounded-lg" /> -
- -
)}
diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index 5efecab..0e25f4e 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.js @@ -1,10 +1,9 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } 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 GenericButton from "@/components/buttons/GenericButton"; import { nip19 } from "nostr-tools"; import { Divider } from "primereact/divider"; import { getTotalFromZaps } from "@/utils/lightning"; @@ -12,6 +11,9 @@ import dynamic from "next/dynamic"; import useWindowWidth from "@/hooks/useWindowWidth"; import appConfig from "@/config/appConfig"; import useTrackDocumentLesson from "@/hooks/tracking/useTrackDocumentLesson"; +import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; +import { useSession } from "next-auth/react"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -27,16 +29,74 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple const { returnImageProxy } = useImageProxy(); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; + const menuRef = useRef(null); + const toastRef = useRef(null); // todo implement real read time needs to be on form const readTime = 120; + const { data: session } = useSession(); - const { isCompleted, isTracking } = useTrackDocumentLesson({ + const { isCompleted, isTracking, markLessonAsCompleted } = useTrackDocumentLesson({ lessonId: lesson?.d, courseId: course?.d, readTime: readTime, paidCourse: isPaid, decryptionPerformed: decryptionPerformed, }); + + const buildMenuItems = () => { + const items = []; + + const hasAccess = session?.user && ( + !isPaid || + decryptionPerformed || + session.user.role?.subscribed + ); + + if (hasAccess) { + items.push({ + label: 'Mark as completed', + icon: 'pi pi-check-circle', + command: async () => { + try { + await markLessonAsCompleted(); + setCompleted && setCompleted(lesson.id); + toastRef.current.show({ + severity: 'success', + summary: 'Success', + detail: 'Lesson marked as completed', + life: 3000 + }); + } catch (error) { + console.error('Failed to mark lesson as completed:', error); + toastRef.current.show({ + severity: 'error', + summary: 'Error', + detail: 'Failed to mark lesson as completed', + life: 3000 + }); + } + } + }); + } + + items.push({ + label: 'Open lesson', + icon: 'pi pi-arrow-up-right', + command: () => { + window.open(`/details/${lesson.id}`, '_blank'); + } + }); + + items.push({ + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + }); + + return items; + }; useEffect(() => { if (!zaps || zapsLoading || zapsError) return; @@ -86,6 +146,7 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple return (
+
lesson background image

{lesson.title}

-
- {lesson.topics && lesson.topics.length > 0 && ( - lesson.topics.map((topic, index) => ( - - )) - )} -
+ +
+
+ {lesson.topics && lesson.topics.length > 0 && ( + lesson.topics.map((topic, index) => ( + + )) + )}
{lesson.summary && (
@@ -115,7 +181,7 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
)}
-
+
avatar image

- -
-
- { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); - }} - /> +
+ +
- {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( -
-

External links:

- -
- )} + {renderContent()}
- {renderContent()}
) } diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index d2b9fb8..feb803b 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -3,7 +3,6 @@ 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"; @@ -12,6 +11,9 @@ import { Divider } from "primereact/divider"; import appConfig from "@/config/appConfig"; import useWindowWidth from "@/hooks/useWindowWidth"; import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson'; +import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; +import { useSession } from "next-auth/react"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -30,8 +32,11 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted const [videoDuration, setVideoDuration] = useState(null); const [videoPlayed, setVideoPlayed] = useState(false); const mdDisplayRef = useRef(null); + const menuRef = useRef(null); + const toastRef = useRef(null); + const { data: session } = useSession(); - const { isCompleted, isTracking } = useTrackVideoLesson({ + const { isCompleted, isTracking, markLessonAsCompleted } = useTrackVideoLesson({ lessonId: lesson?.d, videoDuration, courseId: course?.d, @@ -39,6 +44,61 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted paidCourse: isPaid, decryptionPerformed }); + + const buildMenuItems = () => { + const items = []; + + const hasAccess = session?.user && ( + !isPaid || + decryptionPerformed || + session.user.role?.subscribed + ); + + if (hasAccess) { + items.push({ + label: 'Mark as completed', + icon: 'pi pi-check-circle', + command: async () => { + try { + await markLessonAsCompleted(); + setCompleted(lesson.id); + toastRef.current.show({ + severity: 'success', + summary: 'Success', + detail: 'Lesson marked as completed', + life: 3000 + }); + } catch (error) { + console.error('Failed to mark lesson as completed:', error); + toastRef.current.show({ + severity: 'error', + summary: 'Error', + detail: 'Failed to mark lesson as completed', + life: 3000 + }); + } + } + }); + } + + items.push({ + label: 'Open lesson', + icon: 'pi pi-arrow-up-right', + command: () => { + window.open(`/details/${lesson.id}`, '_blank'); + } + }); + + items.push({ + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + }); + + return items; + }; useEffect(() => { const handleYouTubeMessage = (event) => { @@ -148,20 +208,27 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted return (
+ {renderContent()}
-
+

{lesson.title}

+ +
+
{lesson.topics && lesson.topics.length > 0 && ( lesson.topics.map((topic, index) => ( - + )) )}
-
-
{lesson.summary && ( +
{lesson.summary && (
{lesson.summary.split('\n').map((line, index) => (

{line}

@@ -169,55 +236,31 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
)}
- + -
-
- - { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); - }} - /> -
-
- {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( -
-

External links:

- -
- )}
) diff --git a/src/components/content/documents/DocumentDetails.js b/src/components/content/documents/DocumentDetails.js index 6bc2617..9d28be7 100644 --- a/src/components/content/documents/DocumentDetails.js +++ b/src/components/content/documents/DocumentDetails.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import axios from "axios"; import { useToast } from "@/hooks/useToast"; import { Tag } from "primereact/tag"; @@ -13,6 +13,8 @@ import { getTotalFromZaps } from "@/utils/lightning"; import { useSession } from "next-auth/react"; import useWindowWidth from "@/hooks/useWindowWidth"; import dynamic from "next/dynamic"; +import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -31,25 +33,8 @@ const DocumentDetails = ({ processedEvent, topics, title, summary, image, price, const { showToast } = useToast(); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; - - useEffect(() => { - if (zaps.length > 0) { - const total = getTotalFromZaps(zaps, processedEvent); - setZapAmount(total); - } - }, [zaps, processedEvent]); - - useEffect(() => { - if (isLesson) { - axios.get(`/api/resources/${processedEvent.d}`).then(res => { - if (res.data && res.data.lessons[0]?.courseId) { - setCourse(res.data.lessons[0]?.courseId); - } - }).catch(err => { - console.error('err', err); - }); - } - }, [processedEvent.d, isLesson]); + const menuRef = useRef(null); + const toastRef = useRef(null); const handleDelete = async () => { try { @@ -70,6 +55,63 @@ const DocumentDetails = ({ processedEvent, topics, title, summary, image, price, } } + const authorMenuItems = [ + { + label: 'Edit', + icon: 'pi pi-pencil', + command: () => router.push(`/details/${processedEvent.id}/edit`) + }, + { + label: 'Delete', + icon: 'pi pi-trash', + command: handleDelete + }, + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + } + ]; + + const userMenuItems = [ + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + } + ]; + + if (course) { + userMenuItems.unshift({ + label: isMobileView ? 'Course' : 'Open Course', + icon: 'pi pi-external-link', + command: () => window.open(`/course/${course}`, '_blank') + }); + } + + useEffect(() => { + if (zaps.length > 0) { + const total = getTotalFromZaps(zaps, processedEvent); + setZapAmount(total); + } + }, [zaps, processedEvent]); + + useEffect(() => { + if (isLesson) { + axios.get(`/api/resources/${processedEvent.d}`).then(res => { + if (res.data && res.data.lessons[0]?.courseId) { + setCourse(res.data.lessons[0]?.courseId); + } + }).catch(err => { + console.error('err', err); + }); + } + }, [processedEvent.d, isLesson]); + const renderPaymentMessage = () => { if (session?.user && session.user?.role?.subscribed && decryptedContent) { return @@ -99,7 +141,7 @@ const DocumentDetails = ({ processedEvent, topics, title, summary, image, price, return (
-
+
@@ -127,6 +169,7 @@ const DocumentDetails = ({ processedEvent, topics, title, summary, image, price, return (
+
background image

{title}

-
- {topics && topics.length > 0 && ( - topics.map((topic, index) => ( - - )) - )} - {isLesson && } -
+ +
+
+ {topics && topics.length > 0 && ( + topics.map((topic, index) => ( + + )) + )} + {isLesson && }
{(summary)?.split('\n').map((line, index) => (

{line}

))} - {processedEvent?.additionalLinks && processedEvent?.additionalLinks.length > 0 && ( -
-

Additional Links:

- {processedEvent.additionalLinks.map((link, index) => ( - - ))} -
- )} -
+
avatar image

- +
+ +
-
- {authorView ? ( -
- {renderPaymentMessage()} -
- router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined /> - - { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); - }} - /> -
-
- ) : ( -
- {renderPaymentMessage()} -
- {course && window.open(`/course/${course}`, '_blank')} label={isMobileView ? "Course" : "Open Course"} tooltip="This is a lesson in a course" tooltipOptions={{ position: 'top' }} />} - { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); - }} - /> -
-
- )} +
+ {renderPaymentMessage()}
{renderContent()} diff --git a/src/components/content/videos/VideoDetails.js b/src/components/content/videos/VideoDetails.js index b2bd5bd..167e698 100644 --- a/src/components/content/videos/VideoDetails.js +++ b/src/components/content/videos/VideoDetails.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import axios from "axios"; import { useToast } from "@/hooks/useToast"; import { Tag } from "primereact/tag"; @@ -13,6 +13,8 @@ import { getTotalFromZaps } from "@/utils/lightning"; import { useSession } from "next-auth/react"; import useWindowWidth from "@/hooks/useWindowWidth"; import dynamic from "next/dynamic"; +import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -31,25 +33,8 @@ const VideoDetails = ({ processedEvent, topics, title, summary, image, price, au const { showToast } = useToast(); const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; - - useEffect(() => { - if (isLesson) { - axios.get(`/api/resources/${processedEvent.d}`).then(res => { - if (res.data && res.data.lessons[0]?.courseId) { - setCourse(res.data.lessons[0]?.courseId); - } - }).catch(err => { - console.error('err', err); - }); - } - }, [processedEvent.d, isLesson]); - - useEffect(() => { - if (zaps.length > 0) { - const total = getTotalFromZaps(zaps, processedEvent); - setZapAmount(total); - } - }, [zaps, processedEvent]); + const menuRef = useRef(null); + const toastRef = useRef(null); const handleDelete = async () => { try { @@ -70,6 +55,63 @@ const VideoDetails = ({ processedEvent, topics, title, summary, image, price, au } } + const authorMenuItems = [ + { + label: 'Edit', + icon: 'pi pi-pencil', + command: () => router.push(`/details/${processedEvent.id}/edit`) + }, + { + label: 'Delete', + icon: 'pi pi-trash', + command: handleDelete + }, + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + } + ]; + + const userMenuItems = [ + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } + } + ]; + + if (course) { + userMenuItems.unshift({ + label: isMobileView ? 'Course' : 'Open Course', + icon: 'pi pi-external-link', + command: () => window.open(`/course/${course}`, '_blank') + }); + } + + useEffect(() => { + if (isLesson) { + axios.get(`/api/resources/${processedEvent.d}`).then(res => { + if (res.data && res.data.lessons[0]?.courseId) { + setCourse(res.data.lessons[0]?.courseId); + } + }).catch(err => { + console.error('err', err); + }); + } + }, [processedEvent.d, isLesson]); + + 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 @@ -130,37 +172,9 @@ const VideoDetails = ({ processedEvent, topics, title, summary, image, price, au return null; } - const renderAdditionalLinks = () => { - if (processedEvent?.additionalLinks && processedEvent.additionalLinks.length > 0) { - return ( -
-

Additional Links:

- {processedEvent.additionalLinks.map((link, index) => ( - - ))} -
- ); - } - return null; - }; - return (
+ {renderContent()}
@@ -183,65 +197,39 @@ const VideoDetails = ({ processedEvent, topics, title, summary, image, price, au
-
+
{(summary)?.split('\n').map((line, index) => (

{line}

))} - {renderAdditionalLinks()}
-
- avatar image -

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

+
+
+ avatar image +

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

+
+
+ +
-
- {authorView ? ( -
- {renderPaymentMessage()} -
- router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined /> - - { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); - }} - /> -
-
- ) : ( -
- {renderPaymentMessage()} -
- {course && window.open(`/course/${course}`, '_blank')} label={isMobileView ? "Course" : "Open Course"} tooltip="This is a lesson in a course" tooltipOptions={{ position: 'top' }} />} - { - window.open(`https://habla.news/a/${nAddress}`, '_blank'); - }} - /> -
-
- )} +
+ {renderPaymentMessage()}
diff --git a/src/components/ui/MoreOptionsMenu.js b/src/components/ui/MoreOptionsMenu.js new file mode 100644 index 0000000..de13f99 --- /dev/null +++ b/src/components/ui/MoreOptionsMenu.js @@ -0,0 +1,69 @@ +import React, { useRef } from "react"; +import { Menu } from "primereact/menu"; +import GenericButton from "@/components/buttons/GenericButton"; + +/** + * A reusable component for displaying a "more options" menu with optional additional links section + * + * @param {Object} props - Component props + * @param {Array} props.menuItems - Array of primary menu items + * @param {Array} props.additionalLinks - Array of additional links to add to the menu + * @param {boolean} props.isMobileView - Whether the view is mobile + * @param {function} props.onLinkClick - Function to be called when a link is clicked + */ +const MoreOptionsMenu = ({ + menuItems, + additionalLinks = [], + isMobileView = false, + onLinkClick = (url) => window.open(url, '_blank') +}) => { + const menuRef = useRef(null); + + // Create a copy of the menu items + const updatedMenuItems = [...menuItems]; + + // Add a separator and additional links if they exist + if (additionalLinks && additionalLinks.length > 0) { + // Add separator + updatedMenuItems.push({ separator: true, className: "my-2" }); + + // Add header for additional links + updatedMenuItems.push({ + label: 'EXTERNAL LINKS', + disabled: true, + className: 'text-sm font-semibold text-gray-400' + }); + + // Add each additional link + additionalLinks.forEach((link, index) => { + let hostname; + try { + hostname = new URL(link).hostname; + } catch (e) { + hostname = link; // Fallback if URL parsing fails + } + + updatedMenuItems.push({ + label: `${hostname}`, + icon: 'pi pi-external-link', + command: () => onLinkClick(link) + }); + }); + } + + return ( +
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
+ ); +}; + +export default MoreOptionsMenu; \ No newline at end of file diff --git a/src/hooks/tracking/useTrackDocumentLesson.js b/src/hooks/tracking/useTrackDocumentLesson.js index c61a712..35bc9e7 100644 --- a/src/hooks/tracking/useTrackDocumentLesson.js +++ b/src/hooks/tracking/useTrackDocumentLesson.js @@ -105,7 +105,7 @@ const useTrackDocumentLesson = ({ lessonId, courseId, readTime, paidCourse, decr } }, [timeSpent, markLessonAsCompleted, readTime, isAdmin]); - return { isCompleted, isTracking }; + return { isCompleted, isTracking, markLessonAsCompleted }; }; export default useTrackDocumentLesson; \ No newline at end of file diff --git a/src/hooks/tracking/useTrackVideoLesson.js b/src/hooks/tracking/useTrackVideoLesson.js index f322977..e778fbf 100644 --- a/src/hooks/tracking/useTrackVideoLesson.js +++ b/src/hooks/tracking/useTrackVideoLesson.js @@ -134,7 +134,7 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed, pa } }, [timeSpent, videoDuration, markLessonAsCompleted, isAdmin]); - return { isCompleted, isTracking }; + return { isCompleted, isTracking, markLessonAsCompleted }; }; export default useTrackVideoLesson; \ No newline at end of file