diff --git a/src/components/profile/UserContent.js b/src/components/profile/UserContent.js index 24bc403..a8e5128 100644 --- a/src/components/profile/UserContent.js +++ b/src/components/profile/UserContent.js @@ -1,95 +1,104 @@ import React, { useState, useEffect } from "react"; -import axios from "axios"; import { useRouter } from "next/router"; import { Button } from "primereact/button"; import MenuTab from "@/components/menutab/MenuTab"; import { useLocalStorageWithEffect } from "@/hooks/useLocalStorage"; -import { useNostr } from "@/hooks/useNostr"; +import { useCoursesQuery } from "@/hooks/nostrQueries/content/useCoursesQuery"; +import { useResourcesQuery } from "@/hooks/nostrQueries/content/useResourcesQuery"; +import { useWorkshopsQuery } from "@/hooks/nostrQueries/content/useWorkshopsQuery"; +import { useDraftsQuery } from "@/hooks/apiQueries/useDraftsQuery"; +import { useContentIdsQuery } from "@/hooks/apiQueries/useContentIdsQuery"; +import { useToast } from "@/hooks/useToast"; import ContentList from "@/components/content/lists/ContentList"; import { parseEvent } from "@/utils/nostr"; -import { useToast } from "@/hooks/useToast"; +import { useNDKContext } from "@/context/NDKContext"; + +const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY; const UserContent = () => { const [activeIndex, setActiveIndex] = useState(0); - const [drafts, setDrafts] = useState([]); - const [user, setUser] = useLocalStorageWithEffect('user', {}); - const [courses, setCourses] = useState([]); - const [resources, setResources] = useState([]); - const [workshops, setWorkshops] = useState([]); - const { fetchCourses, fetchResources, fetchWorkshops } = useNostr(); + const [isClient, setIsClient] = useState(false); + const [content, setContent] = useState([]); + const [publishedContent, setPublishedContent] = useState([]); + + const [user] = useLocalStorageWithEffect("user", {}); const router = useRouter(); const { showToast } = useToast(); + const ndk = useNDKContext(); + const { courses, coursesLoading, coursesError } = useCoursesQuery(); + const { resources, resourcesLoading, resourcesError } = useResourcesQuery(); + const { workshops, workshopsLoading, workshopsError } = useWorkshopsQuery(); + const { drafts, draftsLoading, draftsError } = useDraftsQuery(); + const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery(); + + useEffect(() => { + setIsClient(true); + }, []); const contentItems = [ - { label: 'Published', icon: 'pi pi-verified' }, - { label: 'Drafts', icon: 'pi pi-file-edit' }, - { label: 'Resources', icon: 'pi pi-book' }, - { label: 'Workshops', icon: 'pi pi-video' }, - { label: 'Courses', icon: 'pi pi-desktop' } + { label: "Published", icon: "pi pi-verified" }, + { label: "Drafts", icon: "pi pi-file-edit" }, + { label: "Resources", icon: "pi pi-book" }, + { label: "Workshops", icon: "pi pi-video" }, + { label: "Courses", icon: "pi pi-desktop" }, ]; useEffect(() => { - if (user && user.id) { - fetchAllContent(); - } - }, [user]); + const fetchAllContentFromNDK = async (ids) => { + try { + await ndk.connect(); + const filter = { "#d": ids, authors: [AUTHOR_PUBKEY] }; - const fetchAllContent = async () => { - try { - console.log(user.id) - // Fetch drafts from the database - const draftsResponse = await axios.get(`/api/drafts/all/${user.id}`); - const drafts = draftsResponse.data; - console.log('drafts:', drafts); + const uniqueEvents = new Set(); - // Fetch resources, workshops, and courses from Nostr - const resources = await fetchResources(); - const workshops = await fetchWorkshops(); - const courses = await fetchCourses(); - - if (drafts.length > 0) { - setDrafts(drafts); - } - if (resources.length > 0) { - setResources(resources); - } - if (workshops.length > 0) { - setWorkshops(workshops); - } - if (courses.length > 0) { - setCourses(courses); - } - } catch (err) { - console.error(err); - showToast('error', 'Error', 'Failed to fetch content'); - } - }; - - const getContentByIndex = (index) => { - switch (index) { - case 0: - return [] - case 1: - return drafts; - case 2: - return resources.map(resource => { - const { id, content, title, summary, image, published_at } = parseEvent(resource); - return { id, content, title, summary, image, published_at }; + const events = await ndk.fetchEvents(filter); + + events.forEach(event => { + uniqueEvents.add(event); }); - case 3: - return workshops.map(workshop => { - const { id, content, title, summary, image, published_at } = parseEvent(workshop); - return { id, content, title, summary, image, published_at }; - }) - case 4: - return courses.map(course => { - const { id, content, title, summary, image, published_at } = parseEvent(course); - return { id, content, title, summary, image, published_at }; - }) - default: + + console.log('uniqueEvents', uniqueEvents) + return Array.from(uniqueEvents); + } catch (error) { + console.error('Error fetching workshops from NDK:', error); return []; + } + }; + + const fetchContent = async () => { + if (contentIds && isClient) { + const content = await fetchAllContentFromNDK(contentIds); + setPublishedContent(content); + } } - }; + fetchContent(); + }, [contentIds, isClient, ndk]); + + useEffect(() => { + if (isClient) { + const getContentByIndex = (index) => { + switch (index) { + case 0: + return publishedContent.map(parseEvent) || []; + case 1: + return drafts || []; + case 2: + return resources?.map(parseEvent) || []; + case 3: + return workshops?.map(parseEvent) || []; + case 4: + return courses?.map(parseEvent) || []; + default: + return []; + } + }; + + setContent(getContentByIndex(activeIndex)); + } + }, [activeIndex, isClient, drafts, resources, workshops, courses, publishedContent]) + + const isLoading = coursesLoading || resourcesLoading || workshopsLoading || draftsLoading || contentIdsLoading; + const isError = coursesError || resourcesError || workshopsError || draftsError || contentIdsError; return (
@@ -97,13 +106,29 @@ const UserContent = () => {

Your Content

- -
- {getContentByIndex(activeIndex).length > 0 && ( - + {isLoading ? ( +

Loading...

+ ) : isError ? ( +

Error loading content.

+ ) : content.length > 0 ? ( + + ) : ( +

No content available.

)}
diff --git a/src/hooks/apiQueries/useContentIdsQuery.js b/src/hooks/apiQueries/useContentIdsQuery.js new file mode 100644 index 0000000..5767d6f --- /dev/null +++ b/src/hooks/apiQueries/useContentIdsQuery.js @@ -0,0 +1,32 @@ +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export function useContentIdsQuery() { + const [isClient, setIsClient] = useState(false); + + useEffect(() => { + setIsClient(true); + }, []); + + const fetchContentIdsDB = async () => { + try { + const response = await axios.get(`/api/content/all`); + const contentIds = response.data; + return contentIds; + } catch (error) { + console.error('Error fetching contentIds from DB:', error); + return []; + } + }; + + const { data: contentIds, isLoading: contentIdsLoading, error: contentIdsError, refetch: refetchContentIds } = useQuery({ + queryKey: ['contentIds', isClient], + queryFn: fetchContentIdsDB, + staleTime: 1000 * 60 * 30, // 30 minutes + refetchInterval: 1000 * 60 * 30, // 30 minutes + enabled: isClient + }); + + return { contentIds, contentIdsLoading, contentIdsError, refetchContentIds }; +} diff --git a/src/hooks/apiQueries/useDraftsQuery.js b/src/hooks/apiQueries/useDraftsQuery.js new file mode 100644 index 0000000..f657d2f --- /dev/null +++ b/src/hooks/apiQueries/useDraftsQuery.js @@ -0,0 +1,38 @@ +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import { useLocalStorageWithEffect } from '@/hooks/useLocalStorage'; + +export function useDraftsQuery() { + const [isClient, setIsClient] = useState(false); + const [user] = useLocalStorageWithEffect('user', {}); + + useEffect(() => { + setIsClient(true); + }, []); + + const fetchDraftsDB = async () => { + try { + if (!user.id) { + return []; + } + const response = await axios.get(`/api/drafts/all/${user.id}`); + const drafts = response.data; + console.log('drafts:', drafts); + return drafts; + } catch (error) { + console.error('Error fetching drafts from DB:', error); + return []; + } + }; + + const { data: drafts, isLoading: draftsLoading, error: draftsError, refetch: refetchDrafts } = useQuery({ + queryKey: ['drafts', isClient], + queryFn: fetchDraftsDB, + staleTime: 1000 * 60 * 30, // 30 minutes + refetchInterval: 1000 * 60 * 30, // 30 minutes + enabled: isClient && !!user.id, // Only enable if client-side and user ID is available + }); + + return { drafts, draftsLoading, draftsError, refetchDrafts }; +} diff --git a/src/hooks/nostrQueries/content/useAllContentQuery.js b/src/hooks/nostrQueries/content/useAllContentQuery.js new file mode 100644 index 0000000..9ea7fcb --- /dev/null +++ b/src/hooks/nostrQueries/content/useAllContentQuery.js @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useNDKContext } from '@/context/NDKContext'; + +export function useAllContentQuery({ids}) { + const [isClient, setIsClient] = useState(false); + const ndk = useNDKContext(); + + useEffect(() => { + setIsClient(true); + }, []); + +const fetchAllContentFromNDK = async (ids) => { + try { + console.log('Fetching all content from NDK'); + await ndk.connect(); + + const filter = { ids: ids }; + const events = await ndk.fetchEvents(filter); + + if (events && events.size > 0) { + const eventsArray = Array.from(events); + return eventsArray; + } + return []; + } catch (error) { + console.error('Error fetching workshops from NDK:', error); + return []; + } +}; + +const { data: allContent, isLoading: allContentLoading, error: allContentError, refetch: refetchAllContent } = useQuery({ + queryKey: ['allContent', isClient], + queryFn: () => fetchAllContentFromNDK(ids), + staleTime: 1000 * 60 * 30, // 30 minutes + refetchInterval: 1000 * 60 * 30, // 30 minutes + enabled: isClient, + }) + + return { allContent, allContentLoading, allContentError, refetchAllContent } +} \ No newline at end of file diff --git a/src/hooks/useNostr.js b/src/hooks/useNostr.js index cfa52c6..ed9a336 100644 --- a/src/hooks/useNostr.js +++ b/src/hooks/useNostr.js @@ -1,5 +1,6 @@ -import { useCallback, useContext, useRef } from 'react'; +import { useState, useEffect, useCallback, useContext, useRef } from 'react'; import axios from 'axios'; +import { nip57, nip19 } from 'nostr-tools'; import { NostrContext } from '@/context/NostrContext'; import { lnurlEncode } from '@/utils/lnurl'; import { parseEvent } from '@/utils/nostr'; @@ -15,6 +16,8 @@ const defaultRelays = [ "wss://relay.primal.net/" ]; +const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY; + export function useNostr() { const pool = useContext(NostrContext); const subscriptionQueue = useRef([]); @@ -363,6 +366,117 @@ export function useNostr() { [fetchKind0] ); + const fetchResources = useCallback(async () => { + const filter = [{ kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] }]; + const hasRequiredTags = (tags) => { + const hasPlebDevs = tags.some(([tag, value]) => tag === "t" && value === "plebdevs"); + const hasResource = tags.some(([tag, value]) => tag === "t" && value === "resource"); + return hasPlebDevs && hasResource; + }; + + return new Promise((resolve, reject) => { + let resources = []; + const subscription = subscribe( + filter, + { + onevent: (event) => { + if (hasRequiredTags(event.tags)) { + resources.push(event); + } + }, + onerror: (error) => { + console.error('Error fetching resources:', error); + // Don't resolve here, just log the error + }, + onclose: () => { + // Don't resolve here either + }, + }, + 2000 // Adjust the timeout value as needed + ); + + // Set a timeout to resolve the promise after collecting events + setTimeout(() => { + subscription?.close(); + resolve(resources); + }, 2000); // Adjust the timeout value as needed + }); + }, [subscribe]); + + const fetchWorkshops = useCallback(async () => { + const filter = [{ kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] }]; + const hasRequiredTags = (tags) => { + const hasPlebDevs = tags.some(([tag, value]) => tag === "t" && value === "plebdevs"); + const hasWorkshop = tags.some(([tag, value]) => tag === "t" && value === "workshop"); + return hasPlebDevs && hasWorkshop; + }; + + return new Promise((resolve, reject) => { + let workshops = []; + const subscription = subscribe( + filter, + { + onevent: (event) => { + if (hasRequiredTags(event.tags)) { + workshops.push(event); + } + }, + onerror: (error) => { + console.error('Error fetching workshops:', error); + // Don't resolve here, just log the error + }, + onclose: () => { + // Don't resolve here either + }, + }, + 2000 // Adjust the timeout value as needed + ); + + setTimeout(() => { + subscription?.close(); + resolve(workshops); + }, 2000); // Adjust the timeout value as needed + }); + }, [subscribe]); + + const fetchCourses = useCallback(async () => { + const filter = [{ kinds: [30004], authors: [AUTHOR_PUBKEY] }]; + // Do we need required tags for courses? community instead? + // const hasRequiredTags = (tags) => { + // const hasPlebDevs = tags.some(([tag, value]) => tag === "t" && value === "plebdevs"); + // const hasCourse = tags.some(([tag, value]) => tag === "t" && value === "course"); + // return hasPlebDevs && hasCourse; + // }; + + return new Promise((resolve, reject) => { + let courses = []; + const subscription = subscribe( + filter, + { + onevent: (event) => { + // if (hasRequiredTags(event.tags)) { + // courses.push(event); + // } + courses.push(event); + }, + onerror: (error) => { + console.error('Error fetching courses:', error); + // Don't resolve here, just log the error + }, + onclose: () => { + // Don't resolve here either + }, + }, + 2000 // Adjust the timeout value as needed + ); + + setTimeout(() => { + subscription?.close(); + resolve(courses); + }, 2000); // Adjust the timeout value as needed + }); + }, [subscribe]); + const publishResource = useCallback( async (resourceEvent) => { const published = await publish(resourceEvent); @@ -447,5 +561,5 @@ export function useNostr() { [publish] ); - return { subscribe, publish, fetchSingleEvent, fetchSingleNaddrEvent, fetchZapsForEvent, fetchKind0, zapEvent, fetchZapsForEvents, publishResource, publishCourse }; + return { subscribe, publish, fetchSingleEvent, fetchSingleNaddrEvent, fetchZapsForEvent, fetchKind0, fetchResources, fetchWorkshops, fetchCourses, zapEvent, fetchZapsForEvents, publishResource, publishCourse }; } \ No newline at end of file diff --git a/src/pages/details/[slug].js b/src/pages/details/[slug].js index 4337987..b0cfd1a 100644 --- a/src/pages/details/[slug].js +++ b/src/pages/details/[slug].js @@ -5,7 +5,7 @@ import { useImageProxy } from '@/hooks/useImageProxy'; import { getSatAmountFromInvoice } from '@/utils/lightning'; import ZapDisplay from '@/components/zaps/ZapDisplay'; import { Tag } from 'primereact/tag'; -import { nip19 } from 'nostr-tools'; +import { nip19, nip04 } from 'nostr-tools'; import { useLocalStorageWithEffect } from '@/hooks/useLocalStorage'; import Image from 'next/image'; import dynamic from 'next/dynamic'; @@ -27,6 +27,8 @@ const BitcoinConnectPayButton = dynamic( } ); +const privkey = process.env.NEXT_PUBLIC_APP_PRIV_KEY; + export default function Details() { const [event, setEvent] = useState(null); const [processedEvent, setProcessedEvent] = useState({}); @@ -34,6 +36,9 @@ export default function Details() { const [bitcoinConnect, setBitcoinConnect] = useState(false); const [nAddress, setNAddress] = useState(null); const [zapAmount, setZapAmount] = useState(null); + const [paidResource, setPaidResource] = useState(false); + const [decryptedContent, setDecryptedContent] = useState(null); + // const [user, setUser] = useState(null); const ndk = useNDKContext(); const [user] = useLocalStorageWithEffect('user', {}); @@ -42,6 +47,12 @@ export default function Details() { const router = useRouter(); + useEffect(() => { + if (processedEvent.price) { + setPaidResource(true); + } + }, [processedEvent]); + useEffect(() => { if (typeof window === 'undefined') return; @@ -52,6 +63,23 @@ export default function Details() { } }, []); + useEffect(() => { + const decryptContent = async () => { + if (user && paidResource) { + if (!user.purchased.includes(processedEvent.id)) { + // decrypt the content + console.log('privkey', privkey); + console.log('user.pubkey', user.pubkey); + console.log('processedEvent.content', processedEvent.content); + const decryptedContent = await nip04.decrypt(privkey, user.pubkey, processedEvent.content); + console.log('decryptedContent', decryptedContent); + setDecryptedContent(decryptedContent); + } + } + } + decryptContent(); + }, [user, paidResource]); + useEffect(() => { if (router.isReady) { const { slug } = router.query; diff --git a/src/pages/index.js b/src/pages/index.js index e0fc9f7..1f19b08 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -6,7 +6,6 @@ import HeroBanner from '@/components/banner/HeroBanner'; import ResourcesCarousel from '@/components/content/carousels/ResourcesCarousel'; export default function Home() { - return ( <> diff --git a/src/pages/profile.js b/src/pages/profile.js index 63f6474..661cc88 100644 --- a/src/pages/profile.js +++ b/src/pages/profile.js @@ -1,111 +1,98 @@ -import React, { useRef, useState, useEffect } from 'react'; +import React, { useRef, useState, useEffect } from "react"; import { Button } from "primereact/button"; -import { DataTable } from 'primereact/datatable'; -import { Menu } from 'primereact/menu'; -import { Column } from 'primereact/column'; +import { DataTable } from "primereact/datatable"; +import { Menu } from "primereact/menu"; +import { Column } from "primereact/column"; import { useImageProxy } from "@/hooks/useImageProxy"; import { useRouter } from "next/router"; import { useLocalStorageWithEffect } from "@/hooks/useLocalStorage"; -import UserContent from '@/components/profile/UserContent'; +import UserContent from "@/components/profile/UserContent"; import Image from "next/image"; -import BitcoinConnectButton from '@/components/profile/BitcoinConnect'; +import BitcoinConnectButton from "@/components/profile/BitcoinConnect"; const Profile = () => { - const [user, setUser] = useLocalStorageWithEffect('user', {}); - const { returnImageProxy } = useImageProxy(); - const menu = useRef(null); + const [user] = useLocalStorageWithEffect("user", {}); + const { returnImageProxy } = useImageProxy(); + const menu = useRef(null); - const purchases = [ - // { - // cost: 100, - // name: 'Course 1', - // category: 'Education', - // date: '2021-09-01' - // }, - // { - // cost: 200, - // name: 'Course 2', - // category: 'Education', - // date: '2021-09-01' - // }, - // { - // cost: 300, - // name: 'Course 3', - // category: 'Education', - // date: '2021-09-01' - // }, - // { - // cost: 400, - // name: 'Course 4', - // category: 'Education', - // date: '2021-09-01' - // } - ]; + const purchases = []; - const menuItems = [ - { - label: 'Edit', - icon: 'pi pi-pencil', - command: () => { - // Add your edit functionality here - } - }, - { - label: 'Delete', - icon: 'pi pi-trash', - command: () => { - // Add your delete functionality here - } - } - ]; + const menuItems = [ + { + label: "Edit", + icon: "pi pi-pencil", + command: () => { + // Add your edit functionality here + }, + }, + { + label: "Delete", + icon: "pi pi-trash", + command: () => { + // Add your delete functionality here + }, + }, + ]; - const header = ( -
- Purchases + const header = ( +
+ Purchases +
+ ); + + return ( + user && ( +
+
+
+ user's avatar + menu.current.toggle(e)} + > + +
+ +

+ {user.username || "Anon"} +

+

+ {user.pubkey} +

+
+

Connect Your Lightning Wallet

+ +
+
+

Subscription

+

You currently have no active subscription

+
- ); - - - return ( - user && ( -
-
-
- user's avatar - menu.current.toggle(e)}> - -
- - -

{user.username || "Anon"}

-

{user.pubkey}

-
-

Connect Your Lightning Wallet

- -
-
-

Subscription

-

You currently have no active subscription

-
-
- - - - - - - -
- ) + + + + + + + +
) -} + ); +}; -export default Profile \ No newline at end of file +export default Profile; diff --git a/src/utils/nostr.js b/src/utils/nostr.js index df480c5..3344289 100644 --- a/src/utils/nostr.js +++ b/src/utils/nostr.js @@ -67,6 +67,9 @@ export const parseEvent = (event) => { case 'author': eventData.author = tag[1]; break; + case 'price': + eventData.price = tag[1]; + break; // How do we get topics / tags? case 'l': // Grab index 1 and any subsequent elements in the array