diff --git a/package-lock.json b/package-lock.json index d88b6c6..a9b7e3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,8 @@ "react-redux": "^9.1.0", "react-typist": "^2.0.5", "redux": "^5.0.1", - "rehype-raw": "^7.0.0" + "rehype-raw": "^7.0.0", + "uuid": "^9.0.1" }, "devDependencies": { "@types/node": "20.11.21", @@ -4498,6 +4499,14 @@ } } }, + "node_modules/next-auth/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6483,9 +6492,13 @@ "dev": true }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index d4dde79..523ce21 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "react-redux": "^9.1.0", "react-typist": "^2.0.5", "redux": "^5.0.1", - "rehype-raw": "^7.0.0" + "rehype-raw": "^7.0.0", + "uuid": "^9.0.1" }, "devDependencies": { "@types/node": "20.11.21", diff --git a/prisma/migrations/20240320164115_init/migration.sql b/prisma/migrations/20240320231910_init/migration.sql similarity index 89% rename from prisma/migrations/20240320164115_init/migration.sql rename to prisma/migrations/20240320231910_init/migration.sql index c6e642e..1392cb2 100644 --- a/prisma/migrations/20240320164115_init/migration.sql +++ b/prisma/migrations/20240320231910_init/migration.sql @@ -1,10 +1,10 @@ -- CreateTable CREATE TABLE "User" ( - "id" SERIAL NOT NULL, + "id" TEXT NOT NULL, "pubkey" TEXT NOT NULL, "username" TEXT, "avatar" TEXT, - "roleId" INTEGER, + "roleId" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -13,7 +13,7 @@ CREATE TABLE "User" ( -- CreateTable CREATE TABLE "Role" ( - "id" SERIAL NOT NULL, + "id" TEXT NOT NULL, "subscribed" BOOLEAN NOT NULL DEFAULT false, CONSTRAINT "Role_pkey" PRIMARY KEY ("id") @@ -21,10 +21,10 @@ CREATE TABLE "Role" ( -- CreateTable CREATE TABLE "Purchase" ( - "id" SERIAL NOT NULL, - "userId" INTEGER NOT NULL, - "courseId" INTEGER, - "resourceId" INTEGER, + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "courseId" TEXT, + "resourceId" TEXT, "amountPaid" INTEGER NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, @@ -34,8 +34,8 @@ CREATE TABLE "Purchase" ( -- CreateTable CREATE TABLE "Course" ( - "id" SERIAL NOT NULL, - "userId" INTEGER NOT NULL, + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, "price" INTEGER NOT NULL DEFAULT 0, "noteId" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -46,9 +46,9 @@ CREATE TABLE "Course" ( -- CreateTable CREATE TABLE "Resource" ( - "id" SERIAL NOT NULL, - "userId" INTEGER NOT NULL, - "courseId" INTEGER, + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "courseId" TEXT, "price" INTEGER NOT NULL DEFAULT 0, "noteId" TEXT, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d1046d9..6334e16 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,7 +8,7 @@ generator client { } model User { - id Int @id @default(autoincrement()) + id String @id @default(uuid()) pubkey String @unique username String? @unique avatar String? @@ -16,52 +16,51 @@ model User { courses Course[] // Relation field added for courses created by the user resources Resource[] // Relation field added for resources created by the user role Role? @relation(fields: [roleId], references: [id]) - roleId Int? + roleId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Role { - id Int @id @default(autoincrement()) - subscribed Boolean @default(false) - users User[] + id String @id @default(uuid()) + subscribed Boolean @default(false) + users User[] } model Purchase { - id Int @id @default(autoincrement()) + id String @id @default(uuid()) user User @relation(fields: [userId], references: [id]) - userId Int + userId String course Course? @relation(fields: [courseId], references: [id]) - courseId Int? + courseId String? resource Resource? @relation(fields: [resourceId], references: [id]) - resourceId Int? + resourceId String? amountPaid Int // in satoshis createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } - model Course { - id Int @id @default(autoincrement()) - userId Int // Field added to link a course to a user - user User @relation(fields: [userId], references: [id]) // Relation setup for user - price Int @default(0) + id String @id // Client generates UUID + userId String + user User @relation(fields: [userId], references: [id]) + price Int @default(0) resources Resource[] purchases Purchase[] - noteId String? @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + noteId String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Resource { - id Int @id @default(autoincrement()) - userId Int // Field added to link a resource to a user - user User @relation(fields: [userId], references: [id]) // Relation setup for user - course Course? @relation(fields: [courseId], references: [id]) - courseId Int? - price Int @default(0) + id String @id // Client generates UUID + userId String + user User @relation(fields: [userId], references: [id]) + course Course? @relation(fields: [courseId], references: [id]) + courseId String? + price Int @default(0) purchases Purchase[] - noteId String? @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + noteId String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } diff --git a/src/components/forms/ResourceForm.js b/src/components/forms/ResourceForm.js index 1a69dc8..562d0e9 100644 --- a/src/components/forms/ResourceForm.js +++ b/src/components/forms/ResourceForm.js @@ -1,9 +1,14 @@ import React, { useState } from "react"; +import axios from "axios"; import { InputText } from "primereact/inputtext"; import { InputNumber } from "primereact/inputnumber"; import { InputSwitch } from "primereact/inputswitch"; import { Editor } from "primereact/editor"; import { Button } from "primereact/button"; +import { nip04, verifyEvent, nip19 } from "nostr-tools"; +import { useNostr } from "@/hooks/useNostr"; +import { v4 as uuidv4 } from 'uuid'; +import { useLocalStorageWithEffect } from "@/hooks/useLocalStorage"; import 'primeicons/primeicons.css'; const ResourceForm = () => { @@ -12,6 +17,11 @@ const ResourceForm = () => { const [checked, setChecked] = useState(false); const [price, setPrice] = useState(0); const [text, setText] = useState(''); + const [topics, setTopics] = useState(['']); // Initialize with an empty string to show one input by default + + const [user] = useLocalStorageWithEffect('user', {}); + + const { publishAll } = useNostr(); const handleSubmit = (e) => { e.preventDefault(); // Prevents the default form submission mechanism @@ -20,11 +30,84 @@ const ResourceForm = () => { summary, isPaidResource: checked, price: checked ? price : null, - content: text + content: text, + topics: topics.map(topic => topic.trim().toLowerCase()) // Process topics as they are }; - console.log(payload); + buildPaidResource(payload); + }; + + // For images, whether included in the markdown content or not, clients SHOULD use image tags as described in NIP-58. This allows clients to display images in carousel format more easily. + const buildPaidResource = async (payload) => { + // encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY + const encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY ,process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, payload.content); + const newresourceId = uuidv4(); + const event = { + kind: 30402, + content: encryptedContent, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['title', payload.title], + ['summary', payload.summary], + ['t', ...topics], + ['image', ''], + ['d', newresourceId], + ['location', `https://plebdevs.com/resource/${newresourceId}`], + ['published_at', Math.floor(Date.now() / 1000).toString()], + ['price', payload.price] + ] + }; + + console.log('event:', event); + + // Will need to add d tag from db id + // will need to add url/id as location tag + + // first sign the event + const signedEvent = await window.nostr.signEvent(event); + + const eventVerification = await verifyEvent(signedEvent); + + console.log('eventVerification:', eventVerification); + + console.log('signedEvent:', signedEvent); + + const nAddress = nip19.naddrEncode({ + pubkey: signedEvent.pubkey, + kind: signedEvent.kind, + identifier: newresourceId, + }) + + console.log('nAddress:', nAddress); + + const userResponse = await axios.get(`/api/users/${user.pubkey}`) + console.log('userResponse:', userResponse); + const resourcePayload = { + id: newresourceId, + userId: userResponse.data.id, + price: payload.price || 0, + noteId: nAddress, + } + const response = await axios.post(`/api/resources`, resourcePayload); + console.log('response:', response); + + const publishResponse = await publishAll(signedEvent); + console.log('publishResponse:', publishResponse); } + const handleTopicChange = (index, value) => { + const updatedTopics = topics.map((topic, i) => i === index ? value : topic); + setTopics(updatedTopics); + }; + + const addTopic = () => { + setTopics([...topics, '']); // Add an empty string to the topics array + }; + + const removeTopic = (index) => { + const updatedTopics = topics.filter((_, i) => i !== index); + setTopics(updatedTopics); + }; + return (
@@ -37,21 +120,31 @@ const ResourceForm = () => {

Paid Resource

setChecked(e.value)} /> -
- {checked && ( - <> - - setPrice(e.value)} placeholder="Price (sats)" /> - - )} -
+ {checked && ( +
+ setPrice(e.value)} placeholder="Price (sats)" /> +
+ )}
Content setText(e.htmlValue)} style={{ height: '320px' }} />
+
+ {topics.map((topic, index) => ( +
+ handleTopicChange(index, e.target.value)} placeholder="Topic" className="w-full mt-2" /> + {index > 0 && ( +
+ ))} +
+
+
-
); diff --git a/src/components/navbar/user/UserAvatar.js b/src/components/navbar/user/UserAvatar.js index d4e95a4..ff90cdc 100644 --- a/src/components/navbar/user/UserAvatar.js +++ b/src/components/navbar/user/UserAvatar.js @@ -6,14 +6,14 @@ import { useImageProxy } from '@/hooks/useImageProxy'; import { Button } from 'primereact/button'; import { Menu } from 'primereact/menu'; import useWindowWidth from '@/hooks/useWindowWidth'; -import useLocalStorage from '@/hooks/useLocalStorage'; +import {useLocalStorageWithEffect} from '@/hooks/useLocalStorage'; import 'primereact/resources/primereact.min.css'; import 'primeicons/primeicons.css'; import styles from '../navbar.module.css'; const UserAvatar = () => { const router = useRouter(); - const [user, setUser] = useLocalStorage('user', {}); + const [user, setUser] = useLocalStorageWithEffect('user', {}); const [isClient, setIsClient] = useState(false); const { returnImageProxy } = useImageProxy(); const windowWidth = useWindowWidth(); @@ -35,7 +35,6 @@ const UserAvatar = () => { if (!isClient) { return null; // Or return a loader/spinner/placeholder } else if (user && Object.keys(user).length > 0) { - console.log('ahhhhh s:', user); // User exists, show username or pubkey const displayName = user.username || user.pubkey.slice(0, 10) + '...'; diff --git a/src/hooks/useLocalStorage.js b/src/hooks/useLocalStorage.js index 7e7671a..30b9569 100644 --- a/src/hooks/useLocalStorage.js +++ b/src/hooks/useLocalStorage.js @@ -1,28 +1,16 @@ import { useState, useEffect } from 'react'; +// This version of the hook initializes state without immediately attempting to read from localStorage function useLocalStorage(key, initialValue) { - const [storedValue, setStoredValue] = useState(() => { - if (typeof window === 'undefined') { - return initialValue; - } - try { - const item = window.localStorage.getItem(key); - // Added a check to ensure the item is not only present but also a valid JSON string. - return item ? JSON.parse(item) : initialValue; - } catch (error) { - console.log(error); - // Consider removing or correcting the invalid item in localStorage here. - window.localStorage.removeItem(key); // Optional: remove the item that caused the error. - return initialValue; // Revert to initial value if parsing fails. - } - }); + const [storedValue, setStoredValue] = useState(initialValue); + // Function to update localStorage and state const setValue = value => { try { const valueToStore = value instanceof Function ? value(storedValue) : value; - setStoredValue(valueToStore); + setStoredValue(valueToStore); // Update state if (typeof window !== 'undefined') { - window.localStorage.setItem(key, JSON.stringify(valueToStore)); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); // Update localStorage } } catch (error) { console.log(error); @@ -32,4 +20,26 @@ function useLocalStorage(key, initialValue) { return [storedValue, setValue]; } +// Custom hook to handle fetching and setting data from localStorage +export function useLocalStorageWithEffect(key, initialValue) { + const [storedValue, setStoredValue] = useLocalStorage(key, initialValue); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + try { + const item = window.localStorage.getItem(key); + // Only update if the item exists to prevent overwriting the initial value with null + if (item !== null) { + setStoredValue(JSON.parse(item)); + } + } catch (error) { + console.log(error); + } + }, [key]); // Dependencies array ensures this runs once on mount + + return [storedValue, setStoredValue]; +} + export default useLocalStorage; diff --git a/src/hooks/useNostr.js b/src/hooks/useNostr.js index f8dd705..7444e89 100644 --- a/src/hooks/useNostr.js +++ b/src/hooks/useNostr.js @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; -import { SimplePool } from "nostr-tools"; +import { SimplePool, nip19, verifyEvent } from "nostr-tools"; +import { Relay } from 'nostr-tools/relay' const initialRelays = [ "wss://nos.lol/", @@ -140,14 +141,76 @@ export const useNostr = () => { }); } - const publishEvent = async (event) => { + const publishEvent = async (relay, signedEvent) => { + console.log('publishing event to', relay); + return new Promise((resolve, reject) => { + const timeout = 3000 + const wsRelay = new window.WebSocket(relay) + let timer + let isMessageSentSuccessfully = false + + function timedout () { + clearTimeout(timer) + wsRelay.close() + reject(new Error(`relay timeout for ${relay}`)) + } + + timer = setTimeout(timedout, timeout) + + wsRelay.onopen = function () { + clearTimeout(timer) + timer = setTimeout(timedout, timeout) + wsRelay.send(JSON.stringify(['EVENT', signedEvent])) + } + + wsRelay.onmessage = function (msg) { + const m = JSON.parse(msg.data) + if (m[0] === 'OK') { + isMessageSentSuccessfully = true + clearTimeout(timer) + wsRelay.close() + console.log('Successfully sent event to', relay) + resolve() + } + } + + wsRelay.onerror = function (error) { + clearTimeout(timer) + console.log(error) + reject(new Error(`relay error: Failed to send to ${relay}`)) + } + + wsRelay.onclose = function () { + clearTimeout(timer) + if (!isMessageSentSuccessfully) { + reject(new Error(`relay error: Failed to send to ${relay}`)) + } + } + }) + }; + + + const publishAll = async (signedEvent) => { try { - const publishPromises = pool.current.publish(relays, event); - await Promise.all(publishPromises); + const promises = relays.map(relay => publishEvent(relay, signedEvent)); + const results = await Promise.allSettled(promises) + const successfulRelays = [] + const failedRelays = [] + + results.forEach((result, i) => { + if (result.status === 'fulfilled') { + successfulRelays.push(relays[i]) + } else { + failedRelays.push(relays[i]) + } + }) + + return { successfulRelays, failedRelays } } catch (error) { - console.error("Error publishing event:", error); + console.error('Error publishing event:', error); } }; + useEffect(() => { getRelayStatuses(); // Get initial statuses on mount @@ -164,7 +227,7 @@ export const useNostr = () => { return { updateRelays, fetchSingleEvent, - publishEvent, + publishAll, fetchKind0, fetchResources, fetchCourses, diff --git a/src/pages/profile.js b/src/pages/profile.js index e9a5f83..00fc8bc 100644 --- a/src/pages/profile.js +++ b/src/pages/profile.js @@ -1,16 +1,17 @@ -import React, {useRef} 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 { useImageProxy } from "@/hooks/useImageProxy"; import { useRouter } from "next/router"; -import useLocalStorage from "@/hooks/useLocalStorage"; +import { useLocalStorageWithEffect } from "@/hooks/useLocalStorage"; import MenuTab from "@/components/menutab/MenuTab"; import Image from "next/image"; const Profile = () => { - const [user, setUser] = useLocalStorage('user', {}); + const [activeIndex, setActiveIndex] = useState(0); + const [user, setUser] = useLocalStorageWithEffect('user', {}); const { returnImageProxy } = useImageProxy(); const menu = useRef(null); const router = useRouter(); @@ -20,7 +21,7 @@ const Profile = () => { { label: 'Courses', icon: 'pi pi-desktop' }, { label: 'Workshops', icon: 'pi pi-cog' }, { label: 'Resources', icon: 'pi pi-book' }, - ]; + ]; const purchases = [ // { @@ -74,9 +75,10 @@ const Profile = () => { return ( -
-
-
+ user && ( +
+
+
user's avatar { height={100} className="rounded-full my-4" /> - menu.current.toggle(e)}> - -
+ menu.current.toggle(e)}> + +
-

{user.username || "Anon"}

-

{user.pubkey}

-
-

Subscription

-

You currently have no active subscription

-
+
+ + + + + + +
+

Your Content

+
+
+ +
- - - - - - -
-

Your Content

-
-
- -
-
+ ) ) }