Recreate schema to pass in uuid for courses/resources upfront, starting publishing classified, fixed nostr publishing

This commit is contained in:
austinkelsay 2024-03-20 19:42:28 -05:00
parent 7629d1c46b
commit 3113a9d0ee
9 changed files with 292 additions and 111 deletions

21
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<form onSubmit={handleSubmit}>
<div className="p-inputgroup flex-1">
@ -37,21 +120,31 @@ const ResourceForm = () => {
<div className="p-inputgroup flex-1 mt-8 flex-col">
<p className="py-2">Paid Resource</p>
<InputSwitch checked={checked} onChange={(e) => setChecked(e.value)} />
<div className="p-inputgroup flex-1 py-4">
{checked && (
<>
<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>
{checked && (
<div className="p-inputgroup flex-1 py-4">
<InputNumber value={price} onValueChange={(e) => setPrice(e.value)} placeholder="Price (sats)" />
</div>
)}
</div>
<div className="p-inputgroup flex-1 flex-col mt-8">
<span>Content</span>
<Editor value={text} onTextChange={(e) => setText(e.htmlValue)} style={{ height: '320px' }} />
</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 && (
<Button icon="pi pi-times" className="p-button-danger mt-2" onClick={() => removeTopic(index)} />
)}
</div>
))}
<div className="w-full flex flex-row items-end justify-end py-2">
<Button icon="pi pi-plus" onClick={addTopic} />
</div>
</div>
<div className="flex justify-center mt-8">
<Button type="submit" severity="success" outlined label="Submit" />
<Button type="submit" label="Submit" className="p-button-success" />
</div>
</form>
);

View File

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

View File

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

View File

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

View File

@ -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 (
<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">
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={user?.avatar ? returnImageProxy(user.avatar) : `https://secure.gravatar.com/avatar/${user.pubkey}?s=90&d=identicon`}
@ -84,34 +86,35 @@ const Profile = () => {
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>
<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>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]" />
<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>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>
<div className="border-y-2 border-gray-300 mt-12">
<h2 className="text-center my-4">Your Content</h2>
</div>
<div className="flex flex-row w-full justify-between px-4">
<MenuTab items={homeItems} activeIndex={activeIndex} onTabChange={setActiveIndex} />
<Button onClick={() => router.push('/create')} label="Create" severity="success" outlined className="mt-2" />
</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>
<div className="border-y-2 border-gray-300 mt-12">
<h2 className="text-center my-4">Your Content</h2>
</div>
<div className="flex flex-row w-full justify-between px-4">
<MenuTab items={homeItems} />
<Button onClick={() => router.push('/create')} label="Create" severity="success" outlined className="mt-2" />
</div>
</div>
)
)
}