From 3efc82bd0643209a50c264fd35265bc9ee409970 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 30 Mar 2025 11:46:56 -0500 Subject: [PATCH 1/6] more options button in place and works, fully tested, probably still need to align with timestamp --- .../content/courses/CombinedLesson.js | 46 ++++++++++++- .../content/courses/CourseLesson.js | 69 ++++++++++++++++++- src/components/content/courses/VideoLesson.js | 46 ++++++++++++- src/config/appConfig.js | 2 +- src/hooks/tracking/useTrackDocumentLesson.js | 2 +- src/hooks/tracking/useTrackVideoLesson.js | 2 +- 6 files changed, 160 insertions(+), 7 deletions(-) diff --git a/src/components/content/courses/CombinedLesson.js b/src/components/content/courses/CombinedLesson.js index 3428395..0175a35 100644 --- a/src/components/content/courses/CombinedLesson.js +++ b/src/components/content/courses/CombinedLesson.js @@ -12,6 +12,8 @@ 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"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -26,13 +28,15 @@ 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 { isCompleted: videoCompleted, isTracking: videoTracking } = useTrackVideoLesson({ + const { isCompleted: videoCompleted, isTracking: videoTracking, markLessonAsCompleted } = useTrackVideoLesson({ lessonId: lesson?.d, videoDuration, courseId: course?.d, @@ -41,6 +45,33 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple decryptionPerformed }); + const menuItems = [ + { + 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 + }); + } + } + } + ]; + useEffect(() => { const handleYouTubeMessage = (event) => { if (event.origin !== "https://www.youtube.com") return; @@ -168,6 +199,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple return (
+ {isVideo ? renderContent() : ( <>
@@ -252,6 +284,18 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple )}
{!isVideo && renderContent()} + +
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
); }; diff --git a/src/components/content/courses/CourseLesson.js b/src/components/content/courses/CourseLesson.js index 2b04dd8..8e00cf7 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,11 @@ import { getTotalFromZaps } from "@/utils/lightning"; import ZapDisplay from "@/components/zaps/ZapDisplay"; import dynamic from "next/dynamic"; import { useZapsQuery } from "@/hooks/nostrQueries/zaps/useZapsQuery"; +import { Menu } from "primereact/menu"; +import { Toast } from "primereact/toast"; +import GenericButton from "@/components/buttons/GenericButton"; +import useTrackDocumentLesson from "@/hooks/tracking/useTrackDocumentLesson"; +import useWindowWidth from "@/hooks/useWindowWidth"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -14,10 +19,51 @@ 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 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 menuItems = [ + { + 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 + }); + } + } + } + ]; useEffect(() => { if (!zaps || zapsLoading || zapsError) return; @@ -26,6 +72,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,6 +94,7 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid }) => { return (
+
@@ -112,6 +165,18 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
{renderContent()}
+ +
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
) } diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index d2b9fb8..98b4b04 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -12,6 +12,8 @@ import { Divider } from "primereact/divider"; import appConfig from "@/config/appConfig"; import useWindowWidth from "@/hooks/useWindowWidth"; import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson'; +import { Menu } from "primereact/menu"; +import { Toast } from "primereact/toast"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -30,8 +32,10 @@ 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 { isCompleted, isTracking } = useTrackVideoLesson({ + const { isCompleted, isTracking, markLessonAsCompleted } = useTrackVideoLesson({ lessonId: lesson?.d, videoDuration, courseId: course?.d, @@ -39,6 +43,33 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted paidCourse: isPaid, decryptionPerformed }); + + const menuItems = [ + { + 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 + }); + } + } + } + ]; useEffect(() => { const handleYouTubeMessage = (event) => { @@ -148,6 +179,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted return (
+ {renderContent()}
@@ -219,6 +251,18 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
)}
+ +
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
) } diff --git a/src/config/appConfig.js b/src/config/appConfig.js index 784cb30..98cefb8 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -9,7 +9,7 @@ const appConfig = { "wss://purplerelay.com/", "wss://relay.devs.tools/" ], - authorPubkeys: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741", "c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345"], + authorPubkeys: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741", "c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345", "6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4"], customLightningAddresses: [ { // todo remove need for lowercase 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 From b94e90fc031aff210dc5c6eac9a513bd89abfb2d Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 30 Mar 2025 12:34:42 -0500 Subject: [PATCH 2/6] Fix element alignent --- .../content/courses/CombinedLesson.js | 62 ++++++++----- .../content/courses/CourseLesson.js | 60 +++++++----- .../content/courses/DocumentLesson.js | 92 +++++++++++++++---- src/components/content/courses/VideoLesson.js | 60 +++++++----- 4 files changed, 191 insertions(+), 83 deletions(-) diff --git a/src/components/content/courses/CombinedLesson.js b/src/components/content/courses/CombinedLesson.js index 0175a35..13abd8e 100644 --- a/src/components/content/courses/CombinedLesson.js +++ b/src/components/content/courses/CombinedLesson.js @@ -269,33 +269,49 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
{!isVideo && } {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( -
-

External links:

- +
+
+
+

External links:

+ +
+
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
+
+
+ )} + {!lesson?.additionalLinks || lesson.additionalLinks.length === 0 && ( +
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + />
)}
{!isVideo && renderContent()} - -
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
); }; diff --git a/src/components/content/courses/CourseLesson.js b/src/components/content/courses/CourseLesson.js index 8e00cf7..11c7a23 100644 --- a/src/components/content/courses/CourseLesson.js +++ b/src/components/content/courses/CourseLesson.js @@ -116,16 +116,44 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete

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

External links:

- +
+
+

External links:

+ +
+
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
+
+
+ )} + {!lesson?.additionalLinks || lesson.additionalLinks.length === 0 && ( +
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + />
)}
@@ -165,18 +193,6 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete
{renderContent()}
- -
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
) } diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index 5efecab..18c7a26 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.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 ZapDisplay from "@/components/zaps/ZapDisplay"; @@ -12,6 +12,8 @@ import dynamic from "next/dynamic"; import useWindowWidth from "@/hooks/useWindowWidth"; import appConfig from "@/config/appConfig"; import useTrackDocumentLesson from "@/hooks/tracking/useTrackDocumentLesson"; +import { Menu } from "primereact/menu"; +import { Toast } from "primereact/toast"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -27,16 +29,45 @@ 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 { isCompleted, isTracking } = useTrackDocumentLesson({ + const { isCompleted, isTracking, markLessonAsCompleted } = useTrackDocumentLesson({ lessonId: lesson?.d, courseId: course?.d, readTime: readTime, paidCourse: isPaid, decryptionPerformed: decryptionPerformed, }); + + const menuItems = [ + { + 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 + }); + } + } + } + ]; useEffect(() => { if (!zaps || zapsLoading || zapsError) return; @@ -86,6 +117,7 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple return (
+
lesson background image
- {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && ( -
-

External links:

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

External links:

+ +
+
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
+
+
+ )} + {!lesson?.additionalLinks || lesson.additionalLinks.length === 0 && ( +
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
+ )}
{renderContent()} diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index 98b4b04..e11c012 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -238,30 +238,46 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted {lesson?.additionalLinks && lesson.additionalLinks.length > 0 && (
-

External links:

- +
+
+

External links:

+ +
+
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + /> +
+
+
+ )} + {!lesson?.additionalLinks || lesson.additionalLinks.length === 0 && ( +
+ + menuRef.current.toggle(e)} + aria-label="More options" + className="p-button-text" + tooltip={isMobileView ? null : "More options"} + tooltipOptions={{ position: 'top' }} + />
)} - - -
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - />
) From ed41f9a170ff83446cc45c6589ffd183b42f993d Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 30 Mar 2025 17:31:53 -0500 Subject: [PATCH 3/6] Standardize details layout on contet, consolidate options into new generic moreOptionsMenu component --- .../content/combined/CombinedDetails.js | 182 ++++++++--------- .../content/courses/CombinedLesson.js | 104 ++++------ .../content/courses/CourseDetails.js | 129 ++++++------ .../content/courses/CourseLesson.js | 141 +++++++------ .../content/courses/DocumentLesson.js | 105 ++++------ src/components/content/courses/VideoLesson.js | 127 +++++------- .../content/documents/DocumentDetails.js | 179 ++++++++--------- src/components/content/videos/VideoDetails.js | 190 ++++++++---------- src/components/ui/MoreOptionsMenu.js | 69 +++++++ 9 files changed, 578 insertions(+), 648 deletions(-) create mode 100644 src/components/ui/MoreOptionsMenu.js 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 13abd8e..202e993 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"; @@ -14,6 +13,7 @@ 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"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -69,6 +69,20 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple }); } } + }, + { + label: 'Open lesson', + icon: 'pi pi-arrow-up-right', + command: () => { + window.open(`/details/${lesson.id}`, '_blank'); + } + }, + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } } ]; @@ -217,13 +231,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 && (
@@ -233,7 +252,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:

- -
-
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
-
-
- )} - {!lesson?.additionalLinks || lesson.additionalLinks.length === 0 && ( -
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
- )} + {!isVideo && renderContent()}
- {!isVideo && renderContent()}
); }; diff --git a/src/components/content/courses/CourseDetails.js b/src/components/content/courses/CourseDetails.js index 82c9c7f..6c3dc91 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 @@ -114,42 +138,14 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec return null; }; - const renderAdditionalLinks = () => { - if (processedEvent?.additionalLinks && processedEvent.additionalLinks.length > 0) { - return ( -
-

Additional Links:

- {processedEvent.additionalLinks.map((link, index) => ( - - ))} -
- ); - } - return null; - }; - if (!processedEvent || !author) { return
; } 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 +180,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 11c7a23..983000d 100644 --- a/src/components/content/courses/CourseLesson.js +++ b/src/components/content/courses/CourseLesson.js @@ -6,11 +6,12 @@ import { getTotalFromZaps } from "@/utils/lightning"; import ZapDisplay from "@/components/zaps/ZapDisplay"; import dynamic from "next/dynamic"; import { useZapsQuery } from "@/hooks/nostrQueries/zaps/useZapsQuery"; -import { Menu } from "primereact/menu"; import { Toast } from "primereact/toast"; -import GenericButton from "@/components/buttons/GenericButton"; 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"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -62,9 +63,44 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete }); } } + }, + { + label: 'Open lesson', + icon: 'pi pi-arrow-up-right', + command: () => { + window.open(`/details/${lesson.id}`, '_blank'); + } + }, + { + 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'); + } + } } ]; + // Add additional links to menu items if they exist + if (lesson?.additionalLinks && lesson.additionalLinks.length > 0) { + lesson.additionalLinks.forEach((link, index) => { + menuItems.push({ + label: `Link: ${new URL(link).hostname}`, + icon: 'pi pi-external-link', + command: () => { + window.open(link, '_blank'); + } + }); + }); + } + useEffect(() => { if (!zaps || zapsLoading || zapsError) return; @@ -98,78 +134,52 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete
-
+
+

{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:

- -
-
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
-
+
{lesson?.summary && ( +
+ {lesson.summary.split('\n').map((line, index) => ( +

{line}

+ ))}
)} - {!lesson?.additionalLinks || lesson.additionalLinks.length === 0 && ( -
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} +
+
@@ -182,9 +192,6 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete 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 18c7a26..6919fa0 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.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,8 +11,8 @@ import dynamic from "next/dynamic"; import useWindowWidth from "@/hooks/useWindowWidth"; import appConfig from "@/config/appConfig"; import useTrackDocumentLesson from "@/hooks/tracking/useTrackDocumentLesson"; -import { Menu } from "primereact/menu"; import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -66,6 +65,20 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple }); } } + }, + { + label: 'Open lesson', + icon: 'pi pi-arrow-up-right', + command: () => { + window.open(`/details/${lesson.id}`, '_blank'); + } + }, + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } } ]; @@ -131,13 +144,18 @@ const DocumentLesson = ({ 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 && (
@@ -147,7 +165,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:

- -
-
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
-
-
- )} - {!lesson?.additionalLinks || lesson.additionalLinks.length === 0 && ( -
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
- )} + {renderContent()}
- {renderContent()}
) } diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index e11c012..9086996 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,8 +11,8 @@ import { Divider } from "primereact/divider"; import appConfig from "@/config/appConfig"; import useWindowWidth from "@/hooks/useWindowWidth"; import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson'; -import { Menu } from "primereact/menu"; import { Toast } from "primereact/toast"; +import MoreOptionsMenu from "@/components/ui/MoreOptionsMenu"; const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), @@ -68,6 +67,20 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }); } } + }, + { + label: 'Open lesson', + icon: 'pi pi-arrow-up-right', + command: () => { + window.open(`/details/${lesson.id}`, '_blank'); + } + }, + { + label: 'View Nostr note', + icon: 'pi pi-globe', + command: () => { + window.open(`https://habla.news/a/${nAddress}`, '_blank'); + } } ]; @@ -184,16 +197,22 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
-
+

{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}

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

External links:

- -
-
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
-
-
- )} - {!lesson?.additionalLinks || lesson.additionalLinks.length === 0 && ( -
- - menuRef.current.toggle(e)} - aria-label="More options" - className="p-button-text" - tooltip={isMobileView ? null : "More options"} - tooltipOptions={{ position: 'top' }} - /> -
- )}
) diff --git a/src/components/content/documents/DocumentDetails.js b/src/components/content/documents/DocumentDetails.js index 6bc2617..491bb30 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 @@ -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 From 080ab4a4b5bb9091b25f6d2c0e303276893afb83 Mon Sep 17 00:00:00 2001 From: Austin Kelsay <53542748+AustinKelsay@users.noreply.github.com> Date: Mon, 31 Mar 2025 09:41:05 -0500 Subject: [PATCH 4/6] Update src/components/content/courses/CourseLesson.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/components/content/courses/CourseLesson.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/components/content/courses/CourseLesson.js b/src/components/content/courses/CourseLesson.js index 983000d..ad8830b 100644 --- a/src/components/content/courses/CourseLesson.js +++ b/src/components/content/courses/CourseLesson.js @@ -89,17 +89,6 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete ]; // Add additional links to menu items if they exist - if (lesson?.additionalLinks && lesson.additionalLinks.length > 0) { - lesson.additionalLinks.forEach((link, index) => { - menuItems.push({ - label: `Link: ${new URL(link).hostname}`, - icon: 'pi pi-external-link', - command: () => { - window.open(link, '_blank'); - } - }); - }); - } useEffect(() => { if (!zaps || zapsLoading || zapsError) return; From 79b8cf1ff8cb6b519eec6d5ddad8645ee48a96c3 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Mon, 31 Mar 2025 10:02:58 -0500 Subject: [PATCH 5/6] fix course payment button, remove test account from admins --- .../content/courses/CourseDetails.js | 18 ++++++++++-------- src/config/appConfig.js | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/content/courses/CourseDetails.js b/src/components/content/courses/CourseDetails.js index 6c3dc91..d61b595 100644 --- a/src/components/content/courses/CourseDetails.js +++ b/src/components/content/courses/CourseDetails.js @@ -125,13 +125,15 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec if (paidCourse && !decryptionPerformed) { return ( - +
+ +
); } @@ -197,7 +199,7 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec

- Date: Mon, 31 Mar 2025 10:22:25 -0500 Subject: [PATCH 6/6] Add checks for user logged in, paid course and or subscriber for user being able to mark lesson as completed --- .../content/courses/CombinedLesson.js | 76 +++++++++++------- .../content/courses/CourseLesson.js | 78 +++++++++++-------- .../content/courses/DocumentLesson.js | 76 +++++++++++------- src/components/content/courses/VideoLesson.js | 76 +++++++++++------- .../content/documents/DocumentDetails.js | 2 +- 5 files changed, 185 insertions(+), 123 deletions(-) diff --git a/src/components/content/courses/CombinedLesson.js b/src/components/content/courses/CombinedLesson.js index 202e993..f026390 100644 --- a/src/components/content/courses/CombinedLesson.js +++ b/src/components/content/courses/CombinedLesson.js @@ -14,6 +14,7 @@ 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"), @@ -35,6 +36,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple const windowWidth = useWindowWidth(); const isMobileView = windowWidth <= 768; const isVideo = lesson?.type === 'video'; + const { data: session } = useSession(); const { isCompleted: videoCompleted, isTracking: videoTracking, markLessonAsCompleted } = useTrackVideoLesson({ lessonId: lesson?.d, @@ -45,46 +47,60 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple decryptionPerformed }); - const menuItems = [ - { - 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 - }); + 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) => { @@ -270,7 +286,7 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
diff --git a/src/components/content/courses/CourseLesson.js b/src/components/content/courses/CourseLesson.js index ad8830b..34ba3a9 100644 --- a/src/components/content/courses/CourseLesson.js +++ b/src/components/content/courses/CourseLesson.js @@ -12,6 +12,7 @@ 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"), @@ -28,6 +29,7 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete 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; @@ -39,39 +41,51 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete decryptionPerformed }); - const menuItems = [ - { - 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 - }); + 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: () => { @@ -85,10 +99,10 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete window.open(`https://habla.news/a/${addr}`, '_blank'); } } - } - ]; - - // Add additional links to menu items if they exist + }); + + return items; + }; useEffect(() => { if (!zaps || zapsLoading || zapsError) return; @@ -164,7 +178,7 @@ const CourseLesson = ({ lesson, course, decryptionPerformed, isPaid, setComplete
diff --git a/src/components/content/courses/DocumentLesson.js b/src/components/content/courses/DocumentLesson.js index 6919fa0..0e25f4e 100644 --- a/src/components/content/courses/DocumentLesson.js +++ b/src/components/content/courses/DocumentLesson.js @@ -13,6 +13,7 @@ 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"), @@ -32,6 +33,7 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple const toastRef = useRef(null); // todo implement real read time needs to be on form const readTime = 120; + const { data: session } = useSession(); const { isCompleted, isTracking, markLessonAsCompleted } = useTrackDocumentLesson({ lessonId: lesson?.d, @@ -41,46 +43,60 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple decryptionPerformed: decryptionPerformed, }); - const menuItems = [ - { - 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 - }); + 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; @@ -183,7 +199,7 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
diff --git a/src/components/content/courses/VideoLesson.js b/src/components/content/courses/VideoLesson.js index 9086996..feb803b 100644 --- a/src/components/content/courses/VideoLesson.js +++ b/src/components/content/courses/VideoLesson.js @@ -13,6 +13,7 @@ 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"), @@ -33,6 +34,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted const mdDisplayRef = useRef(null); const menuRef = useRef(null); const toastRef = useRef(null); + const { data: session } = useSession(); const { isCompleted, isTracking, markLessonAsCompleted } = useTrackVideoLesson({ lessonId: lesson?.d, @@ -43,46 +45,60 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted decryptionPerformed }); - const menuItems = [ - { - 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 - }); + 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) => { @@ -239,7 +255,7 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
diff --git a/src/components/content/documents/DocumentDetails.js b/src/components/content/documents/DocumentDetails.js index 491bb30..9d28be7 100644 --- a/src/components/content/documents/DocumentDetails.js +++ b/src/components/content/documents/DocumentDetails.js @@ -141,7 +141,7 @@ const DocumentDetails = ({ processedEvent, topics, title, summary, image, price, return (
-
+