diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..b43e32e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "avoid" +} \ No newline at end of file diff --git a/src/components/content/carousels/templates/CourseTemplate.js b/src/components/content/carousels/templates/CourseTemplate.js new file mode 100644 index 0000000..0634b05 --- /dev/null +++ b/src/components/content/carousels/templates/CourseTemplate.js @@ -0,0 +1,183 @@ +import { useState, useEffect } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Tag } from 'primereact/tag'; +import ZapDisplay from '@/components/zaps/ZapDisplay'; +import { nip19 } from 'nostr-tools'; +import Image from 'next/image'; +import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription'; +import { getTotalFromZaps } from '@/utils/lightning'; +import { useImageProxy } from '@/hooks/useImageProxy'; +import { useRouter } from 'next/router'; +import { formatTimestampToHowLongAgo } from '@/utils/time'; +import { ProgressSpinner } from 'primereact/progressspinner'; +import { Message } from 'primereact/message'; +import useWindowWidth from '@/hooks/useWindowWidth'; +import GenericButton from '@/components/buttons/GenericButton'; +import appConfig from '@/config/appConfig'; +import { BookOpen } from 'lucide-react'; + +export function CourseTemplate({ course, showMetaTags = true }) { + const { zaps, zapsLoading, zapsError } = useZapsSubscription({ + event: course, + }); + const [zapAmount, setZapAmount] = useState(0); + const [lessonCount, setLessonCount] = useState(0); + const [nAddress, setNAddress] = useState(null); + const router = useRouter(); + const { returnImageProxy } = useImageProxy(); + const windowWidth = useWindowWidth(); + const isMobile = windowWidth < 768; + + useEffect(() => { + if (zaps.length > 0) { + const total = getTotalFromZaps(zaps, course); + setZapAmount(total); + } + }, [zaps, course]); + + useEffect(() => { + if (course && course?.tags) { + const lessons = course.tags.filter(tag => tag[0] === 'a'); + setLessonCount(lessons.length); + } + }, [course]); + + useEffect(() => { + if (course && course?.d) { + const nAddress = nip19.naddrEncode({ + pubkey: course.pubkey, + kind: course.kind, + identifier: course.d, + relays: appConfig.defaultRelayUrls, + }); + setNAddress(nAddress); + } + }, [course]); + + const shouldShowMetaTags = topic => { + if (!showMetaTags) { + return !['lesson', 'document', 'video', 'course'].includes(topic); + } + return true; + }; + + if (!nAddress) + return ( +
+ +
+ ); + + if (zapsError) return
Error: {zapsError}
; + + return ( + +
router.push(`/course/${nAddress}`)} + > + video thumbnail +
+
+ +
+
+ +
+ {course.name} +
+
+ +
+
+ +
+ {course && + course.topics && + course.topics.map( + (topic, index) => + shouldShowMetaTags(topic) && ( + + {topic} + + ) + )} +
+ {course?.price && course?.price > 0 ? ( + + ) : ( + + )} +
+ +

+ {(course.summary || course.description)?.split('\n').map((line, index) => ( + + {line} + + ))} +

+
+ +

+ {course?.published_at && course.published_at !== '' + ? formatTimestampToHowLongAgo(course.published_at) + : formatTimestampToHowLongAgo(course.created_at)} +

+ router.push(`/course/${nAddress}`)} + size="small" + label="Start Learning" + icon="pi pi-chevron-right" + iconPos="right" + outlined + className="items-center py-2" + /> +
+ + ); +} diff --git a/src/components/content/courses/CourseLesson.js b/src/components/content/courses/CourseLesson.js new file mode 100644 index 0000000..ac8504f --- /dev/null +++ b/src/components/content/courses/CourseLesson.js @@ -0,0 +1,211 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Tag } from 'primereact/tag'; +import Image from 'next/image'; +import { useImageProxy } from '@/hooks/useImageProxy'; +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'), { + ssr: false, +}); + +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; + + const total = getTotalFromZaps(zaps, lesson); + + setZapAmount(total); + }, [zaps, zapsLoading, zapsError, lesson]); + + useEffect(() => { + if (isCompleted && !isTracking && setCompleted) { + setCompleted(lesson.id); + } + }, [isCompleted, isTracking, lesson.id, setCompleted]); + + const renderContent = () => { + if (isPaid && decryptionPerformed) { + return ; + } + if (isPaid && !decryptionPerformed) { + return ( +

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

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

{lesson?.title}

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

{line}

+ ))} +
+ )} +
+
+ +
+ +
+
+
+
+ {lesson && ( +
+ course thumbnail +
+ )} +
+
+
+
+ {renderContent()} +
+
+ ); +}; + +export default CourseLesson;