diff --git a/src/components/content/carousels/DocumentsCarousel.js b/src/components/content/carousels/DocumentsCarousel.js index 1c39f3c..deac41d 100644 --- a/src/components/content/carousels/DocumentsCarousel.js +++ b/src/components/content/carousels/DocumentsCarousel.js @@ -59,14 +59,13 @@ export default function DocumentsCarousel() { // Sort documents by created_at in descending order (most recent first) const sortedDocuments = processedDocuments.sort((a, b) => b.created_at - a.created_at); - if (paidLessons && paidLessons.length > 0) { - // filter out documents that are in the paid lessons array - const filteredDocuments = sortedDocuments.filter(document => !paidLessons.includes(document?.d)); + // Filter out documents that are in paid lessons and combined resources + const filteredDocuments = sortedDocuments.filter(document => + !paidLessons.includes(document?.d) && + !(document.topics?.includes('video') && document.topics?.includes('document')) + ); - setProcessedDocuments(filteredDocuments); - } else { - setProcessedDocuments(sortedDocuments); - } + setProcessedDocuments(filteredDocuments); } else { console.log('No documents fetched or empty array returned'); } diff --git a/src/components/content/carousels/GenericCarousel.js b/src/components/content/carousels/GenericCarousel.js index e6f1500..e905170 100644 --- a/src/components/content/carousels/GenericCarousel.js +++ b/src/components/content/carousels/GenericCarousel.js @@ -5,6 +5,7 @@ import TemplateSkeleton from '@/components/content/carousels/skeletons/TemplateS import { VideoTemplate } from '@/components/content/carousels/templates/VideoTemplate'; import { DocumentTemplate } from '@/components/content/carousels/templates/DocumentTemplate'; import { CourseTemplate } from '@/components/content/carousels/templates/CourseTemplate'; +import { CombinedTemplate } from '@/components/content/carousels/templates/CombinedTemplate'; import debounce from 'lodash/debounce'; const responsiveOptions = [ @@ -74,7 +75,9 @@ export default function GenericCarousel({items, selectedTopic, title}) { value={carouselItems} itemTemplate={(item) => { if (carouselItems.length > 0) { - if (item.type === 'document') { + if (item.topics?.includes('video') && item.topics?.includes('document')) { + return ; + } else if (item.type === 'document') { return ; } else if (item.type === 'video') { return ; diff --git a/src/components/content/carousels/VideosCarousel.js b/src/components/content/carousels/VideosCarousel.js index 89627d7..f3efc9e 100644 --- a/src/components/content/carousels/VideosCarousel.js +++ b/src/components/content/carousels/VideosCarousel.js @@ -62,14 +62,13 @@ export default function VideosCarousel() { const sortedVideos = processedVideos.sort((a, b) => b.created_at - a.created_at); - if (paidLessons && paidLessons.length > 0) { - // filter out videos that are in the paid lessons array - const filteredVideos = sortedVideos.filter(video => !paidLessons.includes(video?.d)); + // Filter out videos that are in paid lessons and combined resources + const filteredVideos = sortedVideos.filter(video => + !paidLessons.includes(video?.d) && + !(video.topics?.includes('video') && video.topics?.includes('document')) + ); - setProcessedVideos(filteredVideos); - } else { - setProcessedVideos(sortedVideos); - } + setProcessedVideos(filteredVideos); } else { console.log('No videos fetched or empty array returned'); } diff --git a/src/components/content/carousels/templates/CombinedTemplate.js b/src/components/content/carousels/templates/CombinedTemplate.js new file mode 100644 index 0000000..0922467 --- /dev/null +++ b/src/components/content/carousels/templates/CombinedTemplate.js @@ -0,0 +1,127 @@ +import { useState, useEffect } from "react"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import ZapDisplay from "@/components/zaps/ZapDisplay"; +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 { nip19 } from "nostr-tools"; +import { Tag } from "primereact/tag"; +import { Message } from "primereact/message"; +import useWindowWidth from "@/hooks/useWindowWidth"; +import GenericButton from "@/components/buttons/GenericButton"; +import { PlayCircle, FileText } from "lucide-react"; + +export function CombinedTemplate({ resource, isLesson, showMetaTags }) { + const { zaps, zapsLoading, zapsError } = useZapsSubscription({ event: resource }); + const [nAddress, setNAddress] = useState(null); + const [zapAmount, setZapAmount] = useState(0); + const router = useRouter(); + const { returnImageProxy } = useImageProxy(); + const windowWidth = useWindowWidth(); + const isMobile = windowWidth < 768; + + useEffect(() => { + if (resource && resource?.d) { + const nAddress = nip19.naddrEncode({ + pubkey: resource.pubkey, + kind: resource.kind, + identifier: resource.d + }); + setNAddress(nAddress); + } + }, [resource]); + + useEffect(() => { + if (zaps.length > 0) { + const total = getTotalFromZaps(zaps, resource); + setZapAmount(total); + } + }, [zaps, resource]); + + const shouldShowMetaTags = (topic) => { + if (!showMetaTags) { + return !["lesson", "document", "video", "course"].includes(topic); + } + return true; + } + + if (zapsError) return
Error: {zapsError}
; + + return ( + +
+ resource thumbnail +
+
+ +
+
+ + +
+
+ +
+ {resource.title} +
+
+ {resource?.price && resource?.price > 0 ? ( + + ) : ( + + )} +
+
+ +
+ {resource?.topics?.map((topic, index) => ( + shouldShowMetaTags(topic) && ( + + {topic} + + ) + ))} + {isLesson && showMetaTags && } +
+

Video / Document

+
+ +

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

+
+ +

{resource?.published_at && resource.published_at !== "" ? ( + formatTimestampToHowLongAgo(resource.published_at) + ) : ( + formatTimestampToHowLongAgo(resource.created_at) + )}

+ router.push(`/details/${nAddress}`)} + size="small" + label="View" + icon="pi pi-chevron-right" + iconPos="right" + outlined + className="items-center py-2" + /> +
+ + ) +} diff --git a/src/components/content/combined/CombinedDetails.js b/src/components/content/combined/CombinedDetails.js new file mode 100644 index 0000000..fb4e616 --- /dev/null +++ b/src/components/content/combined/CombinedDetails.js @@ -0,0 +1,254 @@ +import React, { useEffect, useState } from "react"; +import axios from "axios"; +import { useToast } from "@/hooks/useToast"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import { useRouter } from "next/router"; +import ResourcePaymentButton from "@/components/bitcoinConnect/ResourcePaymentButton"; +import ZapDisplay from "@/components/zaps/ZapDisplay"; +import GenericButton from "@/components/buttons/GenericButton"; +import { useImageProxy } from "@/hooks/useImageProxy"; +import { useZapsSubscription } from "@/hooks/nostrQueries/zaps/useZapsSubscription"; +import { getTotalFromZaps } from "@/utils/lightning"; +import { useSession } from "next-auth/react"; +import useWindowWidth from "@/hooks/useWindowWidth"; +import dynamic from "next/dynamic"; + +const MDDisplay = dynamic( + () => import("@uiw/react-markdown-preview"), + { ssr: false } +); + +const CombinedDetails = ({ processedEvent, topics, title, summary, image, price, author, paidResource, decryptedContent, nAddress, handlePaymentSuccess, handlePaymentError, authorView, isLesson }) => { + const [zapAmount, setZapAmount] = useState(0); + const [course, setCourse] = useState(null); + const router = useRouter(); + const { returnImageProxy } = useImageProxy(); + const { zaps, zapsLoading } = useZapsSubscription({ event: processedEvent }); + const { data: session } = useSession(); + 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 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 ; + } + + if (isLesson && course && session?.user?.purchased?.some(purchase => purchase.courseId === course)) { + const coursePurchase = session?.user?.purchased?.find(purchase => purchase.courseId === course); + return ; + } + + if (paidResource && decryptedContent && author && processedEvent?.pubkey !== session?.user?.pubkey && !session?.user?.role?.subscribed) { + return ; + } + + if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) { + return ; + } + + return null; + }; + + const renderContent = () => { + if (decryptedContent) { + return ( + + ); + } + + if (paidResource && !decryptedContent) { + return ( +
+
+
+ +
+

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

+
+ +
+
+
+ ); + } + + if (processedEvent?.content) { + return ; + } + + return null; + }; + + const renderAdditionalLinks = () => { + if (processedEvent?.additionalLinks?.length > 0) { + return ( +
+

Additional Links:

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

{title}

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

{line}

+ ))} + {renderAdditionalLinks()} +
+
+ 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')} + /> +
+
+ )} +
+
+
+ {renderContent()} +
+ ); +}; + +export default CombinedDetails; diff --git a/src/components/content/courses/CombinedLesson.js b/src/components/content/courses/CombinedLesson.js new file mode 100644 index 0000000..d3ce060 --- /dev/null +++ b/src/components/content/courses/CombinedLesson.js @@ -0,0 +1,259 @@ +import React, { useEffect, useState, useRef, useCallback } 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"; +import dynamic from "next/dynamic"; +import useWindowWidth from "@/hooks/useWindowWidth"; +import appConfig from "@/config/appConfig"; +import useTrackVideoLesson from '@/hooks/tracking/useTrackVideoLesson'; + +const MDDisplay = dynamic( + () => import("@uiw/react-markdown-preview"), + { + ssr: false, + } +); + +const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted }) => { + const [zapAmount, setZapAmount] = useState(0); + const [nAddress, setNAddress] = useState(null); + const [videoDuration, setVideoDuration] = useState(null); + const [videoPlayed, setVideoPlayed] = useState(false); + const mdDisplayRef = 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({ + lessonId: lesson?.d, + videoDuration, + courseId: course?.d, + videoPlayed, + paidCourse: isPaid, + decryptionPerformed + }); + + useEffect(() => { + const handleYouTubeMessage = (event) => { + if (event.origin !== "https://www.youtube.com") return; + + try { + const data = JSON.parse(event.data); + if (data.event === "onReady") { + event.source.postMessage('{"event":"listening"}', "https://www.youtube.com"); + } else if (data.event === "infoDelivery" && data?.info?.currentTime) { + setVideoPlayed(true); + setVideoDuration(data.info?.progressState?.duration); + } + } catch (error) { + console.error("Error parsing YouTube message:", error); + } + }; + + if (isVideo) { + window.addEventListener("message", handleYouTubeMessage); + return () => window.removeEventListener("message", handleYouTubeMessage); + } + }, [isVideo]); + + const checkDuration = useCallback(() => { + if (!isVideo) return; + + const videoElement = mdDisplayRef.current?.querySelector('video'); + const youtubeIframe = mdDisplayRef.current?.querySelector('iframe[src*="youtube.com"]'); + + if (videoElement && videoElement.readyState >= 1) { + setVideoDuration(Math.round(videoElement.duration)); + setVideoPlayed(true); + } else if (youtubeIframe) { + youtubeIframe.contentWindow.postMessage('{"event":"listening"}', '*'); + } + }, [isVideo]); + + useEffect(() => { + if (!zaps || zapsLoading || zapsError) return; + const total = getTotalFromZaps(zaps, lesson); + setZapAmount(total); + }, [zaps, zapsLoading, zapsError, lesson]); + + useEffect(() => { + if (lesson) { + const addr = nip19.naddrEncode({ + pubkey: lesson.pubkey, + kind: lesson.kind, + identifier: lesson.d, + relays: appConfig.defaultRelayUrls + }); + setNAddress(addr); + } + }, [lesson]); + + useEffect(() => { + if (decryptionPerformed && isPaid) { + const timer = setTimeout(checkDuration, 500); + return () => clearTimeout(timer); + } else { + const timer = setTimeout(checkDuration, 3000); + return () => clearTimeout(timer); + } + }, [decryptionPerformed, isPaid, checkDuration]); + + useEffect(() => { + if (isVideo && videoCompleted && !videoTracking) { + setCompleted(lesson.id); + } + }, [videoCompleted, videoTracking, lesson.id, setCompleted, isVideo]); + + const renderContent = () => { + if (isPaid && decryptionPerformed) { + return ( +
+ +
+ ); + } + + if (isPaid && !decryptionPerformed) { + if (isVideo) { + return ( +
+
+
+
+ +
+

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

+
+ ); + } + return ( +
+
+ +
+

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

+
+ ); + } + + if (lesson?.content) { + return ( +
+ +
+ ); + } + + return null; + }; + + return ( +
+ {isVideo ? renderContent() : ( + <> +
+ lesson background image +
+
+ + )} +
+
+
+

{lesson.title}

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

{line}

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

External links:

+ +
+ )} +
+ {!isVideo && renderContent()} +
+ ); +}; + +export default CombinedLesson; diff --git a/src/components/forms/CombinedResourceForm.js b/src/components/forms/CombinedResourceForm.js new file mode 100644 index 0000000..c3de223 --- /dev/null +++ b/src/components/forms/CombinedResourceForm.js @@ -0,0 +1,294 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; +import { useRouter } from 'next/router'; +import { InputText } from 'primereact/inputtext'; +import { InputTextarea } from 'primereact/inputtextarea'; +import { InputNumber } from 'primereact/inputnumber'; +import { InputSwitch } from 'primereact/inputswitch'; +import GenericButton from '@/components/buttons/GenericButton'; +import { useToast } from '@/hooks/useToast'; +import { useSession } from 'next-auth/react'; +import dynamic from 'next/dynamic'; +import { Tooltip } from 'primereact/tooltip'; +import { useEncryptContent } from '@/hooks/encryption/useEncryptContent'; +import { useNDKContext } from "@/context/NDKContext"; +import 'primeicons/primeicons.css'; +import 'primereact/resources/primereact.min.css'; + +const MDEditor = dynamic( + () => import("@uiw/react-md-editor"), + { ssr: false } +); + +const CombinedResourceForm = ({ draft = null, isPublished = false }) => { + const [title, setTitle] = useState(draft?.title || ''); + const [summary, setSummary] = useState(draft?.summary || ''); + const [price, setPrice] = useState(draft?.price || 0); + const [isPaidResource, setIsPaidResource] = useState(draft?.price ? true : false); + const [videoUrl, setVideoUrl] = useState(draft?.videoUrl || ''); + const [content, setContent] = useState(draft?.content || ''); + const [coverImage, setCoverImage] = useState(draft?.image || ''); + const [topics, setTopics] = useState(draft?.topics || ['']); + const [additionalLinks, setAdditionalLinks] = useState(draft?.additionalLinks || ['']); + const [user, setUser] = useState(null); + + const router = useRouter(); + const { data: session } = useSession(); + const { showToast } = useToast(); + const { ndk, addSigner } = useNDKContext(); + const { encryptContent } = useEncryptContent(); + + useEffect(() => { + if (session) { + setUser(session.user); + } + }, [session]); + + const handleContentChange = useCallback((value) => { + setContent(value || ''); + }, []); + + const getVideoEmbed = (url) => { + let embedCode = ''; + + if (url.includes('youtube.com') || url.includes('youtu.be')) { + const videoId = url.split('v=')[1] || url.split('/').pop(); + embedCode = `
`; + } else if (url.includes('vimeo.com')) { + const videoId = url.split('/').pop(); + embedCode = `
`; + } else if (url.includes('.mp4') || url.includes('.mov') || url.includes('.avi') || url.includes('.wmv') || url.includes('.flv') || url.includes('.webm')) { + const baseUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000"; + embedCode = `
`; + } + + return embedCode; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + const userResponse = await axios.get(`/api/users/${user.pubkey}`); + if (!userResponse.data) { + showToast('error', 'Error', 'User not found', 'Please try again.'); + return; + } + + const videoEmbed = videoUrl ? getVideoEmbed(videoUrl) : ''; + const combinedContent = `${videoEmbed}\n\n${content}`; + + const payload = { + title, + summary, + type: 'combined', + price: isPaidResource ? price : null, + content: combinedContent, + image: coverImage, + user: userResponse.data.id, + topics: [...new Set([...topics.map(topic => topic.trim().toLowerCase()), 'video', 'document'])], + additionalLinks: additionalLinks.filter(link => link.trim() !== ''), + }; + + const url = draft ? `/api/drafts/${draft.id}` : '/api/drafts'; + const method = draft ? 'put' : 'post'; + + try { + const response = await axios[method](url, payload); + if (response.status === 200 || response.status === 201) { + showToast('success', 'Success', draft ? 'Content updated successfully.' : 'Content saved as draft.'); + if (response.data?.id) { + router.push(`/draft/${response.data.id}`); + } + } + } catch (error) { + console.error(error); + showToast('error', 'Error', 'Failed to save content. Please try again.'); + } + }; + + const handleTopicChange = (index, value) => { + const updatedTopics = topics.map((topic, i) => i === index ? value : topic); + setTopics(updatedTopics); + }; + + const addTopic = (e) => { + e.preventDefault(); + setTopics([...topics, '']); + }; + + const removeTopic = (e, index) => { + e.preventDefault(); + const updatedTopics = topics.filter((_, i) => i !== index); + setTopics(updatedTopics); + }; + + const handleAdditionalLinkChange = (index, value) => { + const updatedAdditionalLinks = additionalLinks.map((link, i) => i === index ? value : link); + setAdditionalLinks(updatedAdditionalLinks); + }; + + const addAdditionalLink = (e) => { + e.preventDefault(); + setAdditionalLinks([...additionalLinks, '']); + }; + + const removeAdditionalLink = (e, index) => { + e.preventDefault(); + const updatedAdditionalLinks = additionalLinks.filter((_, i) => i !== index); + setAdditionalLinks(updatedAdditionalLinks); + }; + + const buildEvent = async (draft) => { + const dTag = draft.d; + const event = new NDKEvent(ndk); + let encryptedContent; + + const videoEmbed = videoUrl ? getVideoEmbed(videoUrl) : ''; + const combinedContent = `${videoEmbed}\n\n${content}`; + + if (draft?.price) { + encryptedContent = await encryptContent(combinedContent); + } + + event.kind = draft?.price ? 30402 : 30023; + event.content = draft?.price ? encryptedContent : combinedContent; + event.created_at = Math.floor(Date.now() / 1000); + event.pubkey = user.pubkey; + event.tags = [ + ['d', dTag], + ['title', draft.title], + ['summary', draft.summary], + ['image', draft.image], + ...draft.topics.map(topic => ['t', topic]), + ['published_at', Math.floor(Date.now() / 1000).toString()], + ...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []), + ]; + + return event; + }; + + const handlePublishedResource = async (e) => { + e.preventDefault(); + + const updatedDraft = { + title, + summary, + price, + content, + videoUrl, + d: draft.d, + image: coverImage, + topics: [...new Set([...topics.map(topic => topic.trim().toLowerCase()), 'video', 'document'])], + additionalLinks: additionalLinks.filter(link => link.trim() !== '') + }; + + const event = await buildEvent(updatedDraft); + + try { + if (!ndk.signer) { + await addSigner(); + } + + await ndk.connect(); + + const published = await ndk.publish(event); + + if (published) { + const response = await axios.put(`/api/resources/${draft.d}`, { noteId: event.id }); + showToast('success', 'Success', 'Content published successfully.'); + router.push(`/details/${event.id}`); + } else { + showToast('error', 'Error', 'Failed to publish content. Please try again.'); + } + } catch (error) { + console.error(error); + showToast('error', 'Error', 'Failed to publish content. Please try again.'); + } + }; + + return ( +
+
+ setTitle(e.target.value)} placeholder="Title" /> +
+ +
+ setSummary(e.target.value)} placeholder="Summary" rows={5} cols={30} /> +
+ +
+

Paid Resource

+ setIsPaidResource(e.value)} /> + {isPaidResource && ( +
+ + setPrice(e.value)} placeholder="Price (sats)" /> +
+ )} +
+ +
+ setVideoUrl(e.target.value)} placeholder="Video URL (YouTube, Vimeo, or direct video link)" /> +
+ +
+ setCoverImage(e.target.value)} placeholder="Cover Image URL" /> +
+ +
+ Content +
+ +
+
+ +
+ + External Links + + + {additionalLinks.map((link, index) => ( +
+ handleAdditionalLinkChange(index, e.target.value)} placeholder="https://plebdevs.com" className="w-full mt-2" /> + {index > 0 && ( + removeAdditionalLink(e, index)} /> + )} +
+ ))} +
+ +
+ +
+
+ {topics.map((topic, index) => ( +
+ handleTopicChange(index, e.target.value)} placeholder="Topic" className="w-full mt-2" /> + {index > 0 && ( + removeTopic(e, index)} /> + )} +
+ ))} +
+ +
+
+ +
+ +
+ + ); +}; + +export default CombinedResourceForm; diff --git a/src/components/forms/course/LessonSelector.js b/src/components/forms/course/LessonSelector.js index 1a5969b..9caa518 100644 --- a/src/components/forms/course/LessonSelector.js +++ b/src/components/forms/course/LessonSelector.js @@ -67,6 +67,11 @@ const LessonSelector = ({ isPaidCourse, lessons, setLessons, allContent, onNewRe value: content })); + const combinedOptions = filteredContent.filter(content => content?.topics?.includes('video') && content?.topics?.includes('document') && content.kind).map(content => ({ + label: content.title, + value: content + })); + setContentOptions([ { label: 'Draft Documents', @@ -83,6 +88,10 @@ const LessonSelector = ({ isPaidCourse, lessons, setLessons, allContent, onNewRe { label: 'Published Videos', items: videoOptions + }, + { + label: 'Published Combined', + items: combinedOptions } ]); }; diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js index 2574711..bed68cd 100644 --- a/src/pages/course/[slug]/index.js +++ b/src/pages/course/[slug]/index.js @@ -4,6 +4,7 @@ import { parseCourseEvent, parseEvent, findKind0Fields } from "@/utils/nostr"; import CourseDetails from "@/components/content/courses/CourseDetails"; import VideoLesson from "@/components/content/courses/VideoLesson"; import DocumentLesson from "@/components/content/courses/DocumentLesson"; +import CombinedLesson from "@/components/content/courses/CombinedLesson"; import { useNDKContext } from "@/context/NDKContext"; import { useSession } from 'next-auth/react'; import axios from "axios"; @@ -225,6 +226,16 @@ const Course = () => { ); } + const renderLesson = (lesson) => { + if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) { + return ; + } else if (lesson.type === 'video' && !lesson.topics?.includes('document')) { + return ; + } else if (lesson.type === 'document' && !lesson.topics?.includes('video')) { + return ; + } + } + return ( <> {course && paidCourse !== null && ( @@ -260,10 +271,7 @@ const Course = () => { } >
- {lesson.type === 'video' ? - : - - } + {renderLesson(lesson)}
))} diff --git a/src/pages/create.js b/src/pages/create.js index c48e875..42c7b5d 100644 --- a/src/pages/create.js +++ b/src/pages/create.js @@ -3,6 +3,7 @@ import MenuTab from "@/components/menutab/MenuTab"; import DocumentForm from "@/components/forms/DocumentForm"; import VideoForm from "@/components/forms/VideoForm"; import CourseForm from "@/components/forms/course/CourseForm"; +import CombinedResourceForm from "@/components/forms/CombinedResourceForm"; import { useIsAdmin } from "@/hooks/useIsAdmin"; import { useRouter } from "next/router"; import { ProgressSpinner } from "primereact/progressspinner"; @@ -14,6 +15,7 @@ const Create = () => { const homeItems = [ { label: 'Document', icon: 'pi pi-file' }, { label: 'Video', icon: 'pi pi-video' }, + { label: 'Combined', icon: 'pi pi-clone' }, { label: 'Course', icon: 'pi pi-desktop' } ]; @@ -34,8 +36,10 @@ const Create = () => { return ; case 'Document': return ; + case 'Combined': + return ; default: - return null; // or a default component + return null; } }; diff --git a/src/pages/details/[slug]/edit.js b/src/pages/details/[slug]/edit.js index 367fb8b..5892290 100644 --- a/src/pages/details/[slug]/edit.js +++ b/src/pages/details/[slug]/edit.js @@ -4,6 +4,7 @@ import { parseEvent } from "@/utils/nostr"; import DocumentForm from "@/components/forms/DocumentForm"; import VideoForm from "@/components/forms/VideoForm"; import CourseForm from "@/components/forms/course/CourseForm"; +import CombinedResourceForm from "@/components/forms/CombinedResourceForm"; import { useNDKContext } from "@/context/NDKContext"; import { useToast } from "@/hooks/useToast"; @@ -39,8 +40,9 @@ export default function Edit() {

Edit Published Event

{event?.topics.includes('course') && } - {!event?.topics.includes('video') && } - {event?.topics.includes('document') && } + {event?.topics.includes('video') && !event?.topics.includes('document') && } + {event?.topics.includes('document') && !event?.topics.includes('video') && } + {event?.topics.includes('video') && event?.topics.includes('document') && }
); } diff --git a/src/pages/details/[slug]/index.js b/src/pages/details/[slug]/index.js index afd9c35..a412adf 100644 --- a/src/pages/details/[slug]/index.js +++ b/src/pages/details/[slug]/index.js @@ -11,6 +11,7 @@ import { useRouter } from "next/router"; import { ProgressSpinner } from 'primereact/progressspinner'; import axios from 'axios'; import ZapThreadsWrapper from '@/components/ZapThreadsWrapper'; +import CombinedDetails from "@/components/content/combined/CombinedDetails"; // todo: /decrypt is still being called way too much on this page, need to clean up state management @@ -138,7 +139,7 @@ const Details = () => { }; fetchAndProcessEvent(); - }, [router.isReady, router.query, ndk, session, decryptContent, fetchAuthor, showToast]); + }, [router.isReady, router.query, ndk, session]); const handlePaymentSuccess = (response) => { if (response && response?.preimage) { @@ -158,7 +159,14 @@ const Details = () => { if (!author || !event) return null; - const DetailComponent = event.type === "document" ? DocumentDetails : VideoDetails; + const getDetailComponent = () => { + if (event.topics.includes('video') && event.topics.includes('document')) { + return CombinedDetails; + } + return event.type === "document" ? DocumentDetails : VideoDetails; + }; + + const DetailComponent = getDetailComponent(); return ( <> diff --git a/src/pages/draft/[slug]/edit.js b/src/pages/draft/[slug]/edit.js index 1636b2f..56056fb 100644 --- a/src/pages/draft/[slug]/edit.js +++ b/src/pages/draft/[slug]/edit.js @@ -4,6 +4,7 @@ import axios from "axios"; import DocumentForm from "@/components/forms/DocumentForm"; import VideoForm from "@/components/forms/VideoForm"; import CourseForm from "@/components/forms/course/CourseForm"; +import CombinedResourceForm from "@/components/forms/CombinedResourceForm"; import { useIsAdmin } from "@/hooks/useIsAdmin"; const Edit = () => { @@ -38,6 +39,7 @@ const Edit = () => { {draft?.type === 'course' && } {draft?.type === 'video' && } {draft?.type === 'document' && } + {draft?.type === 'combined' && }
); }; diff --git a/src/pages/draft/[slug]/index.js b/src/pages/draft/[slug]/index.js index 1391deb..df9f0f3 100644 --- a/src/pages/draft/[slug]/index.js +++ b/src/pages/draft/[slug]/index.js @@ -116,8 +116,8 @@ export default function Draft() { const handlePostResource = async (resource, videoId) => { const dTag = resource.tags.find(tag => tag[0] === 'd')[1]; - let price - + let price + try { price = resource.tags.find(tag => tag[0] === 'price')[1]; } catch (err) { @@ -241,6 +241,33 @@ export default function Draft() { type = 'video'; break; + case 'combined': + if (draft?.price) { + encryptedContent = await encryptContent(draft.content); + } + + if (draft?.content.includes('?videoKey=')) { + const extractedVideoId = draft.content.split('?videoKey=')[1].split('"')[0]; + videoId = extractedVideoId; + } + + event.kind = draft?.price ? 30402 : 30023; + event.content = draft?.price ? encryptedContent : draft.content; + event.created_at = Math.floor(Date.now() / 1000); + event.pubkey = user.pubkey; + event.tags = [ + ['d', NewDTag], + ['title', draft.title], + ['summary', draft.summary], + ['image', draft.image], + ...draft.topics.map(topic => ['t', topic]), + ['published_at', Math.floor(Date.now() / 1000).toString()], + ...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []), + ...(draft?.additionalLinks ? draft.additionalLinks.filter(link => link !== 'https://plebdevs.com').map(link => ['r', link]) : []), + ]; + + type = 'combined'; + break; default: return null; } @@ -264,13 +291,13 @@ export default function Draft() {

{draft?.title}

{draft?.summary && ( -

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

{line}

- ))} -
- )} -

+
+ {draft.summary.split('\n').map((line, index) => ( +

{line}

+ ))} +
+ )} +

{draft?.price && (

Price: {draft.price}

)} @@ -305,7 +332,7 @@ export default function Draft() {

)} -

{draft?.createdAt && formatDateTime(draft?.createdAt)}

+

{draft?.createdAt && formatDateTime(draft?.createdAt)}

{draft && (