Merge pull request #2 from AustinKelsay/feature/dev-journey-checkboxes

Feature/dev journey checkboxes
This commit is contained in:
Austin Kelsay 2024-11-21 18:08:53 -06:00 committed by GitHub
commit 46dc942fad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1029 additions and 34 deletions

View File

@ -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');
}

View File

@ -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 <CombinedTemplate key={item.id} resource={item} isLesson={lessons.includes(item?.d)} />;
} else if (item.type === 'document') {
return <DocumentTemplate key={item.id} document={item} isLesson={lessons.includes(item?.d)} />;
} else if (item.type === 'video') {
return <VideoTemplate key={item.id} video={item} isLesson={lessons.includes(item?.d)} />;

View File

@ -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');
}

View File

@ -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 <div>Error: {zapsError}</div>;
return (
<Card className="overflow-hidden group hover:shadow-xl transition-all duration-300 bg-gray-800 m-2 border-none">
<div className="relative w-full h-0" style={{ paddingBottom: "56.25%" }}>
<Image
alt="resource thumbnail"
src={returnImageProxy(resource.image)}
quality={100}
layout="fill"
objectFit="cover"
className="rounded-md"
/>
<div className="absolute inset-0 bg-gradient-to-br from-primary/80 to-primary-foreground/50" />
<div className="absolute top-4 right-4 flex items-center gap-1 bg-black/50 text-white px-3 py-1 rounded-full">
<ZapDisplay zapAmount={zapAmount} event={resource} zapsLoading={zapsLoading && zapAmount === 0} />
</div>
<div className="absolute bottom-4 left-4 flex gap-2">
<PlayCircle className="w-6 h-6 text-white" />
<FileText className="w-6 h-6 text-white" />
</div>
</div>
<CardHeader className="flex flex-row justify-between items-center p-4 border-b border-gray-700">
<div className="flex items-center gap-4">
<CardTitle className="text-xl sm:text-2xl text-[#f8f8ff]">{resource.title}</CardTitle>
</div>
<div>
{resource?.price && resource?.price > 0 ? (
<Message className={`${isMobile ? "py-1 text-xs" : "py-2"} whitespace-nowrap`} icon="pi pi-lock" severity="info" text={`${resource.price} sats`} />
) : (
<Message className={`${isMobile ? "py-1 text-xs" : "py-2"} whitespace-nowrap`} icon="pi pi-lock-open" severity="success" text="Free" />
)}
</div>
</CardHeader>
<CardContent className={`${isMobile ? "px-3" : ""} pt-6 pb-2 w-full flex flex-row justify-between items-start`}>
<div className="flex flex-wrap gap-2 max-w-[65%]">
{resource?.topics?.map((topic, index) => (
shouldShowMetaTags(topic) && (
<Tag size="small" key={index} className="px-2 py-1 text-sm text-[#f8f8ff]">
{topic}
</Tag>
)
))}
{isLesson && showMetaTags && <Tag size="small" className="px-2 py-1 text-sm text-[#f8f8ff]" value="lesson" />}
</div>
<p className="font-bold text-gray-300">Video / Document</p>
</CardContent>
<CardDescription className={`${isMobile ? "w-full p-3" : "p-6"} py-2 pt-0 text-base text-neutral-50/90 dark:text-neutral-900/90 overflow-hidden min-h-[4em] flex items-center max-w-[100%]`}
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: "2"
}}>
<p className="line-clamp-2 text-wrap break-words">{(resource.summary || resource.description)?.split('\n').map((line, index) => (
<span className="text-wrap break-words" key={index}>{line}</span>
))}</p>
</CardDescription>
<CardFooter className={`flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border-t border-gray-700 pt-4 ${isMobile ? "px-3" : ""}`}>
<p className="text-sm text-gray-300">{resource?.published_at && resource.published_at !== "" ? (
formatTimestampToHowLongAgo(resource.published_at)
) : (
formatTimestampToHowLongAgo(resource.created_at)
)}</p>
<GenericButton
onClick={() => router.push(`/details/${nAddress}`)}
size="small"
label="View"
icon="pi pi-chevron-right"
iconPos="right"
outlined
className="items-center py-2"
/>
</CardFooter>
</Card>
)
}

View File

@ -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 <GenericButton tooltipOptions={{ position: 'top' }} tooltip="You are subscribed so you can access all paid content" icon="pi pi-check" label="Subscribed" severity="success" outlined size="small" className="cursor-default hover:opacity-100 hover:bg-transparent focus:ring-0" />;
}
if (isLesson && course && session?.user?.purchased?.some(purchase => purchase.courseId === course)) {
const coursePurchase = session?.user?.purchased?.find(purchase => purchase.courseId === course);
return <GenericButton tooltipOptions={{ position: 'top' }} tooltip={`You have this lesson through purchasing the course it belongs to. You paid ${coursePurchase?.course?.price} sats for the course.`} icon="pi pi-check" label={`Paid ${coursePurchase?.course?.price} sats`} severity="success" outlined size="small" className="cursor-default hover:opacity-100 hover:bg-transparent focus:ring-0" />;
}
if (paidResource && decryptedContent && author && processedEvent?.pubkey !== session?.user?.pubkey && !session?.user?.role?.subscribed) {
return <GenericButton icon="pi pi-check" label={`Paid ${processedEvent.price} sats`} severity="success" outlined size="small" className="cursor-default hover:opacity-100 hover:bg-transparent focus:ring-0" />;
}
if (paidResource && author && processedEvent?.pubkey === session?.user?.pubkey) {
return <GenericButton tooltipOptions={{ position: 'top' }} tooltip={`You created this paid content, users must pay ${processedEvent.price} sats to access it`} icon="pi pi-check" label={`Price ${processedEvent.price} sats`} severity="success" outlined size="small" className="cursor-default hover:opacity-100 hover:bg-transparent focus:ring-0" />;
}
return null;
};
const renderContent = () => {
if (decryptedContent) {
return (
<MDDisplay className='p-2 rounded-lg w-full' source={decryptedContent} />
);
}
if (paidResource && !decryptedContent) {
return (
<div className="w-full px-4">
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center bg-gray-800">
<div className="mx-auto py-auto">
<i className="pi pi-lock text-[60px] text-red-500"></i>
</div>
<p className="text-center text-xl text-red-500 mt-4">
This content is paid and needs to be purchased before viewing.
</p>
<div className="flex flex-row items-center justify-center w-full mt-4">
<ResourcePaymentButton
lnAddress={author?.lud16}
amount={price}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
resourceId={processedEvent.d}
/>
</div>
</div>
</div>
);
}
if (processedEvent?.content) {
return <MDDisplay className='p-2 rounded-lg w-full' source={processedEvent.content} />;
}
return null;
};
const renderAdditionalLinks = () => {
if (processedEvent?.additionalLinks?.length > 0) {
return (
<div className="my-4">
<p>Additional Links:</p>
{processedEvent.additionalLinks.map((link, index) => (
<div key={index} className="mb-2">
<a
className="text-blue-500 hover:underline hover:text-blue-600 break-words"
href={link}
target="_blank"
rel="noopener noreferrer"
style={{
wordBreak: 'break-word',
overflowWrap: 'break-word',
display: 'inline-block',
maxWidth: '100%'
}}
>
{link}
</a>
</div>
))}
</div>
);
}
return null;
};
return (
<div className="w-full">
<div className="relative w-full h-[400px] mb-8">
<Image
alt="background image"
src={returnImageProxy(image)}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-20"></div>
</div>
<div className="w-full mx-auto px-4 py-8 -mt-32 relative z-10 max-mob:px-0 max-tab:px-0">
<div className="mb-8 bg-gray-800/70 rounded-lg p-4 max-mob:rounded-t-none max-tab:rounded-t-none">
<div className="flex flex-row items-center justify-between w-full">
<h1 className='text-4xl font-bold text-white'>{title}</h1>
<div className="flex flex-wrap gap-2">
{topics?.map((topic, index) => (
<Tag className='text-[#f8f8ff]' key={index} value={topic} />
))}
{isLesson && <Tag size="small" className="text-[#f8f8ff]" value="lesson" />}
</div>
</div>
{summary?.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
{renderAdditionalLinks()}
<div className='flex items-center justify-between'>
<div className='flex items-center'>
<Image
alt="avatar image"
src={returnImageProxy(author?.avatar, author?.username)}
width={50}
height={50}
className="rounded-full mr-4"
/>
<p className='text-lg text-white'>
By{' '}
<a rel='noreferrer noopener' target='_blank' className='text-blue-300 hover:underline'>
{author?.username}
</a>
</p>
</div>
<ZapDisplay
zapAmount={zapAmount}
event={processedEvent}
zapsLoading={zapsLoading && zapAmount === 0}
/>
</div>
<div className='w-full mt-8 flex flex-wrap justify-between items-center'>
{authorView ? (
<div className='flex space-x-2 mt-4 sm:mt-0'>
{renderPaymentMessage()}
<div className="flex flex-row gap-2">
<GenericButton onClick={() => router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined />
<GenericButton onClick={handleDelete} label="Delete" severity='danger' outlined />
<GenericButton
tooltip={isMobileView ? null : "View Nostr Note"}
tooltipOptions={{ position: 'left' }}
icon="pi pi-external-link"
outlined
onClick={() => window.open(`https://habla.news/a/${nAddress}`, '_blank')}
/>
</div>
</div>
) : (
<div className="w-full flex flex-row justify-between gap-2">
{renderPaymentMessage()}
<div className="flex flex-row justify-end gap-2">
{course && (
<GenericButton
size={isMobileView ? 'small' : null}
outlined
icon="pi pi-external-link"
onClick={() => window.open(`/course/${course}`, '_blank')}
label={isMobileView ? "Course" : "Open Course"}
tooltip="This is a lesson in a course"
tooltipOptions={{ position: 'top' }}
/>
)}
<GenericButton
size={isMobileView ? 'small' : null}
tooltip={isMobileView ? null : "View Nostr Note"}
tooltipOptions={{ position: 'left' }}
icon="pi pi-external-link"
outlined
onClick={() => window.open(`https://habla.news/a/${nAddress}`, '_blank')}
/>
</div>
</div>
)}
</div>
</div>
</div>
{renderContent()}
</div>
);
};
export default CombinedDetails;

View File

@ -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 (
<div ref={mdDisplayRef}>
<MDDisplay className={`${isVideo ? 'p-0' : 'p-2'} rounded-lg w-full`} source={lesson.content} />
</div>
);
}
if (isPaid && !decryptionPerformed) {
if (isVideo) {
return (
<div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center relative overflow-hidden">
<div
className="absolute inset-0 opacity-50"
style={{
backgroundImage: `url(${lesson.image})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
></div>
<div className="absolute inset-0 bg-black bg-opacity-50"></div>
<div className="mx-auto py-auto z-10">
<i className="pi pi-lock text-[100px] text-red-500"></i>
</div>
<p className="text-center text-xl text-red-500 z-10 mt-4">
This content is paid and needs to be purchased before viewing.
</p>
</div>
);
}
return (
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center bg-gray-100 dark:bg-gray-800">
<div className="mx-auto py-auto">
<i className="pi pi-lock text-[60px] text-red-500"></i>
</div>
<p className="text-center text-xl text-red-500 mt-4">
This content is paid and needs to be purchased before viewing.
</p>
</div>
);
}
if (lesson?.content) {
return (
<div ref={mdDisplayRef}>
<MDDisplay className={`${isVideo ? 'p-0' : 'p-2'} rounded-lg w-full`} source={lesson.content} />
</div>
);
}
return null;
};
return (
<div className="w-full">
{isVideo ? renderContent() : (
<>
<div className="relative w-[80%] h-[200px] mx-auto mb-24">
<Image
alt="lesson background image"
src={returnImageProxy(lesson.image)}
fill
className="object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-20"></div>
</div>
</>
)}
<div className={`${isVideo ? 'bg-gray-800/90 rounded-lg p-4 m-4' : 'w-full mx-auto px-4 py-8 -mt-32 relative z-10'}`}>
<div className={`${!isVideo && 'mb-8 bg-gray-800/70 rounded-lg p-4'}`}>
<div className="flex flex-row items-center justify-between w-full">
<h1 className='text-3xl font-bold text-white'>{lesson.title}</h1>
<div className="flex flex-wrap gap-2">
{lesson.topics && lesson.topics.length > 0 && (
lesson.topics.map((topic, index) => (
<Tag className='text-white' key={index} value={topic}></Tag>
))
)}
</div>
</div>
<div className='text-xl text-gray-200 mb-4 mt-4'>{lesson.summary && (
<div className="text-xl mt-4">
{lesson.summary.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</div>
)}
</div>
<div className='flex items-center justify-between'>
<div className='flex items-center'>
<Image
alt="avatar image"
src={returnImageProxy(lesson.author?.avatar, lesson.author?.username)}
width={50}
height={50}
className="rounded-full mr-4"
/>
<p className='text-lg text-white'>
By{' '}
<a rel='noreferrer noopener' target='_blank' className='text-blue-300 hover:underline'>
{lesson.author?.username || lesson.author?.name || lesson.author?.pubkey}
</a>
</p>
</div>
<ZapDisplay
zapAmount={zapAmount}
event={lesson}
zapsLoading={zapsLoading}
/>
</div>
<div className="w-full flex flex-row justify-end">
<GenericButton
tooltip={isMobileView ? null : "View Nostr Note"}
tooltipOptions={{ position: 'left' }}
icon="pi pi-external-link"
outlined
onClick={() => {
window.open(`https://habla.news/a/${nAddress}`, '_blank');
}}
/>
</div>
</div>
{!isVideo && <Divider />}
{lesson?.additionalLinks && lesson.additionalLinks.length > 0 && (
<div className='mt-6 bg-gray-800/90 rounded-lg p-4'>
<h3 className='text-lg font-semibold mb-2 text-white'>External links:</h3>
<ul className='list-disc list-inside text-white'>
{lesson.additionalLinks.map((link, index) => (
<li key={index}>
<a href={link} target="_blank" rel="noopener noreferrer" className='text-blue-300 hover:underline'>
{new URL(link).hostname}
</a>
</li>
))}
</ul>
</div>
)}
</div>
{!isVideo && renderContent()}
</div>
);
};
export default CombinedLesson;

View File

@ -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 = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}?enablejsapi=1" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
} else if (url.includes('vimeo.com')) {
const videoId = url.split('/').pop();
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://player.vimeo.com/video/${videoId}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
} 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 = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><video src="${baseUrl}/api/get-video-url?videoKey=${encodeURIComponent(url)}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" controls></video></div>`;
}
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 (
<form onSubmit={handleSubmit}>
<div className="p-inputgroup flex-1">
<InputText value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
</div>
<div className="p-inputgroup flex-1 mt-4">
<InputTextarea value={summary} onChange={(e) => setSummary(e.target.value)} placeholder="Summary" rows={5} cols={30} />
</div>
<div className="p-inputgroup flex-1 mt-4 flex-col">
<p className="py-2">Paid Resource</p>
<InputSwitch checked={isPaidResource} onChange={(e) => setIsPaidResource(e.value)} />
{isPaidResource && (
<div className="p-inputgroup flex-1 py-4">
<i className="pi pi-bolt p-inputgroup-addon text-2xl text-yellow-500"></i>
<InputNumber value={price} onValueChange={(e) => setPrice(e.value)} placeholder="Price (sats)" />
</div>
)}
</div>
<div className="p-inputgroup flex-1 mt-4">
<InputText value={videoUrl} onChange={(e) => setVideoUrl(e.target.value)} placeholder="Video URL (YouTube, Vimeo, or direct video link)" />
</div>
<div className="p-inputgroup flex-1 mt-4">
<InputText value={coverImage} onChange={(e) => setCoverImage(e.target.value)} placeholder="Cover Image URL" />
</div>
<div className="p-inputgroup flex-1 flex-col mt-4">
<span>Content</span>
<div data-color-mode="dark">
<MDEditor
value={content}
onChange={handleContentChange}
height={350}
/>
</div>
</div>
<div className="mt-8 flex-col w-full">
<span className="pl-1 flex items-center">
External Links
<i className="pi pi-info-circle ml-2 cursor-pointer"
data-pr-tooltip="Add any relevant external links that pair with this content (these links are currently not encrypted for 'paid' content)"
data-pr-position="right"
data-pr-at="right+5 top"
data-pr-my="left center-2"
style={{ fontSize: '1rem', color: 'var(--primary-color)' }}
/>
</span>
{additionalLinks.map((link, index) => (
<div className="p-inputgroup flex-1" key={index}>
<InputText value={link} onChange={(e) => handleAdditionalLinkChange(index, e.target.value)} placeholder="https://plebdevs.com" className="w-full mt-2" />
{index > 0 && (
<GenericButton icon="pi pi-times" className="p-button-danger mt-2" onClick={(e) => removeAdditionalLink(e, index)} />
)}
</div>
))}
<div className="w-full flex flex-row items-end justify-end py-2">
<GenericButton icon="pi pi-plus" onClick={addAdditionalLink} />
</div>
<Tooltip target=".pi-info-circle" />
</div>
<div className="mt-8 flex-col w-full">
{topics.map((topic, index) => (
<div className="p-inputgroup flex-1" key={index}>
<InputText value={topic} onChange={(e) => handleTopicChange(index, e.target.value)} placeholder="Topic" className="w-full mt-2" />
{index > 0 && (
<GenericButton icon="pi pi-times" className="p-button-danger mt-2" onClick={(e) => removeTopic(e, index)} />
)}
</div>
))}
<div className="w-full flex flex-row items-end justify-end py-2">
<GenericButton icon="pi pi-plus" onClick={addTopic} />
</div>
</div>
<div className="flex justify-center mt-8">
<GenericButton type="submit" severity="success" outlined label={draft ? "Update" : "Submit"} />
</div>
</form>
);
};
export default CombinedResourceForm;

View File

@ -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
}
]);
};

View File

@ -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 <CombinedLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} />;
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
return <VideoLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} />;
} else if (lesson.type === 'document' && !lesson.topics?.includes('video')) {
return <DocumentLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} />;
}
}
return (
<>
{course && paidCourse !== null && (
@ -260,10 +271,7 @@ const Course = () => {
}
>
<div className="w-full py-4 rounded-b-lg">
{lesson.type === 'video' ?
<VideoLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} /> :
<DocumentLesson lesson={lesson} course={course} decryptionPerformed={decryptionPerformed} isPaid={paidCourse} setCompleted={setCompleted} />
}
{renderLesson(lesson)}
</div>
</AccordionTab>
))}

View File

@ -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 <VideoForm />;
case 'Document':
return <DocumentForm />;
case 'Combined':
return <CombinedResourceForm />;
default:
return null; // or a default component
return null;
}
};

View File

@ -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() {
<div className="w-[80vw] max-w-[80vw] mx-auto my-8 flex flex-col justify-center">
<h2 className="text-center mb-8">Edit Published Event</h2>
{event?.topics.includes('course') && <CourseForm draft={event} isPublished />}
{!event?.topics.includes('video') && <VideoForm draft={event} isPublished />}
{event?.topics.includes('document') && <DocumentForm draft={event} isPublished />}
{event?.topics.includes('video') && !event?.topics.includes('document') && <VideoForm draft={event} isPublished />}
{event?.topics.includes('document') && !event?.topics.includes('video') && <DocumentForm draft={event} isPublished />}
{event?.topics.includes('video') && event?.topics.includes('document') && <CombinedResourceForm draft={event} isPublished />}
</div>
);
}

View File

@ -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 (
<>

View File

@ -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' && <CourseForm draft={draft} />}
{draft?.type === 'video' && <VideoForm draft={draft} />}
{draft?.type === 'document' && <DocumentForm draft={draft} />}
{draft?.type === 'combined' && <CombinedResourceForm draft={draft} />}
</div>
);
};

View File

@ -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() {
</div>
<h1 className='text-4xl mt-4'>{draft?.title}</h1>
<p className='text-xl mt-4'>{draft?.summary && (
<div className="text-xl mt-4">
{draft.summary.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</div>
)}
</p>
<div className="text-xl mt-4">
{draft.summary.split('\n').map((line, index) => (
<p key={index}>{line}</p>
))}
</div>
)}
</p>
{draft?.price && (
<p className='text-lg mt-4'>Price: {draft.price}</p>
)}
@ -305,7 +332,7 @@ export default function Draft() {
</p>
)}
</div>
<p className="pt-8 text-sm text-gray-400">{draft?.createdAt && formatDateTime(draft?.createdAt)}</p>
<p className="pt-8 text-sm text-gray-400">{draft?.createdAt && formatDateTime(draft?.createdAt)}</p>
</div>
<div className='flex flex-col max-tab:mt-12 max-mob:mt-12'>
{draft && (