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.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 (
+
+
+
+
+
+
{title}
+
+ {topics?.map((topic, index) => (
+
+ ))}
+ {isLesson && }
+
+
+ {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')}
+ />
+
+
+ )}
+
+
+
+ {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.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 && (
+
+ )}
+
+ {!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 (
+
+ );
+};
+
+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 && (