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