Improvements to profile pge and all user content views on profile

This commit is contained in:
austinkelsay 2024-08-06 14:50:32 -05:00
parent aa60c23611
commit b61e927c0c
9 changed files with 443 additions and 176 deletions

View File

@ -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 (
<div className="w-[90vw] mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
@ -97,13 +106,29 @@ const UserContent = () => {
<h2 className="text-center my-4">Your Content</h2>
</div>
<div className="flex flex-row w-full justify-between px-4">
<MenuTab items={contentItems} activeIndex={activeIndex} onTabChange={setActiveIndex} />
<Button onClick={() => router.push('/create')} label="Create" severity="success" outlined className="mt-2" />
<MenuTab
items={contentItems}
activeIndex={activeIndex}
onTabChange={setActiveIndex}
/>
<Button
onClick={() => router.push("/create")}
label="Create"
severity="success"
outlined
className="mt-2"
/>
</div>
<div className="w-full mx-auto my-8">
<div className="w-full mx-auto my-8">
{getContentByIndex(activeIndex).length > 0 && (
<ContentList content={getContentByIndex(activeIndex)} />
{isLoading ? (
<p>Loading...</p>
) : isError ? (
<p>Error loading content.</p>
) : content.length > 0 ? (
<ContentList content={content} />
) : (
<p>No content available.</p>
)}
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,6 @@ import HeroBanner from '@/components/banner/HeroBanner';
import ResourcesCarousel from '@/components/content/carousels/ResourcesCarousel';
export default function Home() {
return (
<>
<Head>

View File

@ -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 = (
<div className="flex flex-wrap align-items-center justify-content-between gap-2">
<span className="text-xl text-900 font-bold text-white">Purchases</span>
const header = (
<div className="flex flex-wrap align-items-center justify-content-between gap-2">
<span className="text-xl text-900 font-bold text-white">Purchases</span>
</div>
);
return (
user && (
<div className="w-[90vw] mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
<div className="w-[85vw] flex flex-col justify-center mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
<div className="relative flex w-full items-center justify-center">
<Image
alt="user's avatar"
src={returnImageProxy(user.avatar, user.pubkey)}
width={100}
height={100}
className="rounded-full my-4"
/>
<i
className="pi pi-ellipsis-h absolute right-24 text-2xl my-4 cursor-pointer hover:opacity-75"
onClick={(e) => menu.current.toggle(e)}
></i>
<Menu model={menuItems} popup ref={menu} />
</div>
<h1 className="text-center text-2xl my-2">
{user.username || "Anon"}
</h1>
<h2 className="text-center text-xl my-2 truncate max-tab:px-4 max-mob:px-4">
{user.pubkey}
</h2>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Connect Your Lightning Wallet</h2>
<BitcoinConnectButton />
</div>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Subscription</h2>
<p className="text-center">You currently have no active subscription</p>
<Button
label="Subscribe"
className="p-button-raised p-button-success w-auto my-2 text-[#f8f8ff]"
/>
</div>
</div>
);
return (
user && (
<div className="w-[90vw] mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
<div className="w-[85vw] flex flex-col justify-center mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
<div className="relative flex w-full items-center justify-center">
<Image
alt="user's avatar"
src={returnImageProxy(user.avatar, user.pubkey)}
width={100}
height={100}
className="rounded-full my-4"
/>
<i className="pi pi-ellipsis-h absolute right-24 text-2xl my-4 cursor-pointer hover:opacity-75"
onClick={(e) => menu.current.toggle(e)}></i>
<Menu model={menuItems} popup ref={menu} />
</div>
<h1 className="text-center text-2xl my-2">{user.username || "Anon"}</h1>
<h2 className="text-center text-xl my-2 truncate max-tab:px-4 max-mob:px-4">{user.pubkey}</h2>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Connect Your Lightning Wallet</h2>
<BitcoinConnectButton />
</div>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Subscription</h2>
<p className="text-center">You currently have no active subscription</p>
<Button label="Subscribe" className="p-button-raised p-button-success w-auto my-2 text-[#f8f8ff]" />
</div>
</div>
<DataTable emptyMessage="No purchases" value={purchases} tableStyle={{ minWidth: '100%' }} header={header}>
<Column field="cost" header="Cost"></Column>
<Column field="name" header="Name"></Column>
<Column field="category" header="Category"></Column>
<Column field="date" header="Date"></Column>
</DataTable>
<UserContent />
</div>
)
<DataTable
emptyMessage="No purchases"
value={purchases}
tableStyle={{ minWidth: "100%" }}
header={header}
>
<Column field="cost" header="Cost"></Column>
<Column field="name" header="Name"></Column>
<Column field="category" header="Category"></Column>
<Column field="date" header="Date"></Column>
</DataTable>
<UserContent />
</div>
)
}
);
};
export default Profile
export default Profile;

View File

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