Full login /signup flow working, basic profile page and user avatar, pulling in resources from nostr and rendering

This commit is contained in:
austinkelsay 2024-02-11 16:26:33 -06:00
parent 16513c71ec
commit 595c553dd2
17 changed files with 338 additions and 103 deletions

View File

@ -2,7 +2,7 @@
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
images: { images: {
domains: ['fakestoreapi.com'], domains: ['localhost'],
}, },
} }

View File

@ -3,6 +3,7 @@ CREATE TABLE "User" (
"id" SERIAL NOT NULL, "id" SERIAL NOT NULL,
"pubkey" TEXT NOT NULL, "pubkey" TEXT NOT NULL,
"username" TEXT, "username" TEXT,
"avatar" TEXT,
"roleId" INTEGER, "roleId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,

View File

@ -11,6 +11,7 @@ model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
pubkey String @unique pubkey String @unique
username String? @unique username String? @unique
avatar String?
purchased Purchase[] purchased Purchase[]
role Role? @relation(fields: [roleId], references: [id]) role Role? @relation(fields: [roleId], references: [id])
roleId Int? roleId Int?

View File

@ -1,32 +1,17 @@
import React from 'react'; import React, {useRef} from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import UserAvatar from './user/UserAvatar';
import { Button } from 'primereact/button';
import { Menubar } from 'primereact/menubar'; import { Menubar } from 'primereact/menubar';
import { useSelector } from 'react-redux'; import { useRouter } from 'next/router';
import 'primereact/resources/primereact.min.css'; import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css'; import 'primeicons/primeicons.css';
import styles from './navbar.module.css'; import styles from './navbar.module.css';
const Navbar = () => { const Navbar = () => {
const router = useRouter(); const router = useRouter();
const user = useSelector((state) => state.user);
const end = (
(user && user?.username || user.pubkey) ?
<h1>{user.username || user.pubkey}</h1>
:
<Button
label={"Login"}
icon="pi pi-user"
className="text-[#f8f8ff]"
rounded
onClick={() => router.push('/login')}
/>
);
const start = ( const start = (
<div className={styles.titleContainer}> <div onClick={() => router.push('/')} className={styles.titleContainer}>
<Image <Image
alt="logo" alt="logo"
src="/plebdevs-guy.jpg" src="/plebdevs-guy.jpg"
@ -39,7 +24,7 @@ const Navbar = () => {
); );
return ( return (
<Menubar start={start} end={end} className='px-[5%]' /> <Menubar start={start} end={UserAvatar} className='px-[5%]' />
); );
}; };

View File

@ -5,6 +5,7 @@
justify-content: center; justify-content: center;
padding-left: 6%; padding-left: 6%;
padding-right: 6%; padding-right: 6%;
cursor: pointer;
} }
.logo { .logo {

View File

@ -0,0 +1,81 @@
import React, {useRef} from 'react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useImageProxy } from '@/hooks/useImageProxy';
import { Button } from 'primereact/button';
import { Menu } from 'primereact/menu';
import { useSelector, useDispatch } from 'react-redux';
import { setUser } from '@/redux/reducers/userReducer';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import styles from '../navbar.module.css';
const UserAvatar = () => {
const router = useRouter();
const dispatch = useDispatch();
const user = useSelector((state) => state.user.user);
const { returnImageProxy } = useImageProxy();
const menu = useRef(null);
const handleLogout = () => {
window.localStorage.removeItem('pubkey');
dispatch(setUser(null));
router.push('/');
}
let userAvatar;
if (user) {
// User exists, show username or pubkey
const displayName = user.username || user.pubkey;
const items = [
{
label: displayName,
items: [
{
label: 'Profile',
icon: 'pi pi-user',
command: () => router.push('/profile')
},
{
label: 'Logout',
icon: 'pi pi-power-off',
command: handleLogout
}
]
}
];
userAvatar = (
<>
<div onClick={(event) => menu.current.toggle(event)} className='flex flex-row items-center justify-between cursor-pointer hover:opacity-75'>
{user.avatar && (
<Image
alt="logo"
src={returnImageProxy(user.avatar)}
width={50}
height={50}
className={styles.logo}
/>
)}
</div>
<Menu model={items} popup ref={menu} />
</>
);
} else {
userAvatar = (
<Button
label="Login"
icon="pi pi-user"
className="text-[#f8f8ff]"
rounded
onClick={() => router.push('/login')}
/>
);
}
return userAvatar;
};
export default UserAvatar;

View File

@ -3,60 +3,29 @@ import React, { useState, useEffect } from 'react';
import { Button } from 'primereact/button'; import { Button } from 'primereact/button';
import { Carousel } from 'primereact/carousel'; import { Carousel } from 'primereact/carousel';
import { Tag } from 'primereact/tag'; import { Tag } from 'primereact/tag';
import { useSelector } from 'react-redux';
import { parseResourceEvent } from '@/utils/nostr';
export default function ResourcesCarousel() { export default function ResourcesCarousel() {
const [courses, setCourses] = useState([ const resources = useSelector((state) => state.events.resources);
{
"title": "Lightning Wallet Frontend",
"description": "Write your first code and learn Frontend from scratch to build a simple lightning wallet using HTML/CSS, Javascript, and React",
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdev2_750__422_px_1200__630_px.jpg',
"price": 45000
},
{
"title": "Lightning Wallet Backend",
"description": "Learn Backend from scratch and build a simple Lightning Wallet backend with a server, API, Database, and Lightning node using NodeJS",
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdevs-thumbnail.png',
"price": 70000
},
{
"title": "Lightning Wallet Frontend",
"description": "Write your first code and learn Frontend from scratch to build a simple lightning wallet using HTML/CSS, Javascript, and React",
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdev2_750__422_px_1200__630_px.jpg',
"price": 45000
},
{
"title": "Lightning Wallet Backend",
"description": "Learn Backend from scratch and build a simple Lightning Wallet backend with a server, API, Database, and Lightning node using NodeJS",
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdevs-thumbnail.png',
"price": 70000
},
{
"title": "Lightning Wallet Frontend",
"description": "Write your first code and learn Frontend from scratch to build a simple lightning wallet using HTML/CSS, Javascript, and React",
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdev2_750__422_px_1200__630_px.jpg',
"price": 45000
},
{
"title": "Lightning Wallet Backend",
"description": "Learn Backend from scratch and build a simple Lightning Wallet backend with a server, API, Database, and Lightning node using NodeJS",
"thumbnail": 'https://emeralize.s3.amazonaws.com/course/cover_images/plebdevs-thumbnail.png',
"price": 70000
}
]);
const productTemplate = (course) => { console.log('Resources:', resources);
const resourceTemplate = (resource) => {
const { content, title, summary, image, published_at } = parseResourceEvent(resource);
return ( return (
<div className="flex flex-col items-center w-full px-4"> <div className="flex flex-col items-center w-full px-4">
<div className="w-86 h-60 bg-gray-200 overflow-hidden rounded-md shadow-lg"> <div className="w-86 h-60 bg-gray-200 overflow-hidden rounded-md shadow-lg">
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover object-center" /> <img src={image} alt={title} className="w-full h-full object-cover object-center" />
</div> </div>
<div className='text-center'> <div className='text-center'>
<h4 className="mb-1 text-center">{course.title}</h4> <h4 className="mb-1 font-bold text-center">{title}</h4>
<h6 className="mt-0 mb-3 text-center">{course.price} sats</h6> <p className="text-center">{summary}</p>
<div className="flex flex-row items-center justify-center gap-2"> <div className="flex flex-row items-center justify-center gap-2">
<Button icon="pi pi-search" rounded /> <Button icon="pi pi-search" rounded />
<Button icon="pi pi-star-fill" rounded severity="success" /> <Button icon="pi pi-star-fill" rounded severity="success" />
</div> </div>
<p className="text-center mt-2">Published on {published_at}</p>
</div> </div>
</div> </div>
); );
@ -65,7 +34,7 @@ export default function ResourcesCarousel() {
return ( return (
<> <>
<h1 className="text-2xl font-bold ml-[6%] my-4">Resources</h1> <h1 className="text-2xl font-bold ml-[6%] my-4">Resources</h1>
<Carousel value={courses} numVisible={3} itemTemplate={productTemplate} /> <Carousel value={resources} numVisible={3} itemTemplate={resourceTemplate} />
</> </>
) )
} }

View File

@ -0,0 +1,12 @@
import React from "react"
export const useImageProxy = () => {
const returnImageProxy = (image) => {
const proxyUrl = `${process.env.NEXT_PUBLIC_PROXY_URL}?imageUrl=${encodeURIComponent(image)}`;
return proxyUrl;
}
return { returnImageProxy };
}

View File

@ -1,16 +1,18 @@
import { useCallback, useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { useNostr } from './useNostr';
import axios from 'axios'; import axios from 'axios';
import { setPubkey, setUsername } from "@/redux/reducers/userReducer"; import { setUser } from "@/redux/reducers/userReducer";
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { findKind0Username } from "@/utils/nostr"; import { findKind0Fields } from "@/utils/nostr";
import { useToast } from './useToast'; import { useToast } from './useToast';
export const useLogin = () => { export const useLogin = () => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const router = useRouter(); const router = useRouter();
const { showToast } = useToast(); const { showToast } = useToast();
const { fetchKind0 } = useNostr();
// Attempt Auto Login on render // Attempt Auto Login on render
useEffect(() => { useEffect(() => {
@ -22,11 +24,26 @@ export const useLogin = () => {
try { try {
const response = await axios.get(`/api/users/${publicKey}`); const response = await axios.get(`/api/users/${publicKey}`);
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
dispatch(setPubkey(publicKey)); dispatch(setUser(response.data));
if (response.data.username) { } else if (response.status === 204) {
dispatch(setUsername(response.data.username)); // User not found, create a new user
const kind0 = await fetchKind0([{ authors: [publicKey], kinds: [0] }], {});
const fields = await findKind0Fields(kind0);
const payload = { pubkey: publicKey, ...fields };
try {
const createUserResponse = await axios.post(`/api/users`, payload);
if (createUserResponse.status === 201) {
;
window.localStorage.setItem('pubkey', publicKey);
dispatch(setUser(createUserResponse.data));
} else {
console.error('Error creating user:', createUserResponse);
}
} catch (createError) {
console.error('Error creating user:', createError);
showToast('error', 'Error Creating User', 'Failed to create user');
} }
router.push('/');
} }
} catch (error) { } catch (error) {
console.error('Error during auto login:', error); console.error('Error during auto login:', error);
@ -52,22 +69,21 @@ export const useLogin = () => {
const response = await axios.get(`/api/users/${publicKey}`); const response = await axios.get(`/api/users/${publicKey}`);
if (response.status !== 200) throw new Error('User not found'); if (response.status !== 200) throw new Error('User not found');
dispatch(setPubkey(publicKey));
window.localStorage.setItem('pubkey', publicKey); window.localStorage.setItem('pubkey', publicKey);
if (response.data.username) dispatch(setUsername(response.data.username)); dispatch(setUser(response.data));
router.push('/'); router.push('/');
} catch (error) { } catch (error) {
// User not found, create a new user // User not found, create a new user
const kind0 = await findKind0Username({ authors: [publicKey], kinds: [0] }); // Adjust based on actual implementation const kind0 = await fetchKind0([{ authors: [publicKey], kinds: [0] }], {});
const username = kind0 ? kind0 : undefined; const fields = await findKind0Fields(kind0);
const payload = { pubkey: publicKey, ...(username && { username }) }; const payload = { pubkey: publicKey, ...fields };
try { try {
const createUserResponse = await axios.post(`/api/users`, payload); const createUserResponse = await axios.post(`/api/users`, payload);
if (createUserResponse.status === 201) { if (createUserResponse.status === 201) {
dispatch(setPubkey(publicKey)); ;
window.localStorage.setItem('pubkey', publicKey); window.localStorage.setItem('pubkey', publicKey);
if (username) dispatch(setUsername(username)); dispatch(setUser(createUserResponse.data));
router.push('/'); router.push('/');
} else { } else {
console.error('Error creating user:', createUserResponse); console.error('Error creating user:', createUserResponse);
@ -77,14 +93,14 @@ export const useLogin = () => {
showToast('error', 'Error Creating User', 'Failed to create user'); showToast('error', 'Error Creating User', 'Failed to create user');
} }
} }
}, [dispatch, router, showToast]); }, [dispatch, router, showToast, fetchKind0]);
const anonymousLogin = useCallback(() => { const anonymousLogin = useCallback(() => {
try { try {
const secretKey = generateSecretKey(); const secretKey = generateSecretKey();
const publicKey = getPublicKey(secretKey); const publicKey = getPublicKey(secretKey);
dispatch(setPubkey(publicKey)); dispatch(setUser({ pubkey: publicKey }));
window.localStorage.setItem('pubkey', publicKey); window.localStorage.setItem('pubkey', publicKey);
window.localStorage.setItem('seckey', secretKey); window.localStorage.setItem('seckey', secretKey);
router.push('/'); router.push('/');

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { SimplePool, relayInit, nip19 } from "nostr-tools"; import { SimplePool, relayInit, nip19 } from "nostr-tools";
import { useDispatch } from "react-redux"; import { useDispatch } from "react-redux";
import { addResource } from "@/redux/reducers/eventsReducer";
import { initialRelays } from "@/redux/reducers/userReducer"; import { initialRelays } from "@/redux/reducers/userReducer";
export const useNostr = () => { export const useNostr = () => {
@ -61,6 +62,36 @@ export const useNostr = () => {
}); });
}; };
const fetchResources = async () => {
const filter = [{kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
const params = {seenOnEnabled: true};
const seenEventIds = new Set(); // Set to keep track of event IDs that have been processed
const hasPlebdevsTag = (eventData) => {
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
};
const sub = pool.current.subscribeMany(relays, filter, {
...params,
onevent: (event) => {
// Assuming event has a unique identifier under `id` field
if (!seenEventIds.has(event.id) && hasPlebdevsTag(event.tags)) {
seenEventIds.add(event.id); // Add event ID to the set to track it's been processed
dispatch(addResource(event));
}
},
onerror: (error) => {
console.error("Error fetching resources:", error);
},
oneose: () => {
console.log("Subscription closed");
sub.close();
}
});
}
const fetchSingleEvent = async (id) => { const fetchSingleEvent = async (id) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }]); const sub = pool.current.subscribeMany(relays, [{ ids: [id] }]);
@ -101,6 +132,7 @@ export const useNostr = () => {
fetchSingleEvent, fetchSingleEvent,
publishEvent, publishEvent,
fetchKind0, fetchKind0,
fetchResources,
getRelayStatuses, getRelayStatuses,
}; };
}; };

View File

@ -3,7 +3,7 @@ import { Provider } from "react-redux";
import { store } from "@/redux/store"; import { store } from "@/redux/store";
import Navbar from '@/components/navbar/Navbar'; import Navbar from '@/components/navbar/Navbar';
import { ToastProvider } from '@/hooks/useToast'; import { ToastProvider } from '@/hooks/useToast';
import Layout from '@/components/layout'; import Layout from '@/components/Layout';
import '@/styles/globals.css' import '@/styles/globals.css'
import 'primereact/resources/themes/lara-dark-indigo/theme.css'; import 'primereact/resources/themes/lara-dark-indigo/theme.css';

View File

@ -0,0 +1,27 @@
import axios from 'axios';
export default async function handler(req, res) {
const { imageUrl } = req.query;
// Validate the imageUrl query parameter
if (!imageUrl) {
return res.status(400).json({ error: 'Missing imageUrl query parameter' });
}
try {
const response = await axios({
method: 'GET',
url: imageUrl,
responseType: 'stream',
});
// Forward the content type
res.setHeader('Content-Type', response.headers['content-type']);
// Stream the image from the external source to the client
response.data.pipe(res);
} catch (error) {
console.error('Image proxy error:', error);
res.status(500).json({ error: 'Failed to fetch image' });
}
}

View File

@ -1,8 +1,17 @@
import Head from 'next/head' import Head from 'next/head'
import React, {useCallback, useEffect, useState} from 'react';
import CoursesCarousel from '@/components/courses/CoursesCarousel' import CoursesCarousel from '@/components/courses/CoursesCarousel'
import ResourcesCarousel from '@/components/resources/ResourcesCarousel' import ResourcesCarousel from '@/components/resources/ResourcesCarousel'
import { useNostr } from '@/hooks/useNostr'
export default function Home() { export default function Home() {
const { fetchResources } = useNostr();
useEffect(() => {
console.log('Fetching resources');
fetchResources();
}, [fetchResources]);
return ( return (
<> <>
<Head> <Head>

55
src/pages/profile.js Normal file
View File

@ -0,0 +1,55 @@
import React from "react";
import { Button } from "primereact/button";
import { DataTable } from 'primereact/datatable';
import { Column } from 'primereact/column';
import { useSelector } from "react-redux";
import { useImageProxy } from "@/hooks/useImageProxy";
import Image from "next/image";
const Profile = () => {
const user = useSelector((state) => state.user.user);
const { returnImageProxy } = useImageProxy();
const purchases = [
{ code: '123', name: 'Product 1', category: 'Category 1', quantity: 1 },
{ code: '124', name: 'Product 2', category: 'Category 2', quantity: 2 },
{ code: '125', name: 'Product 3', category: 'Category 3', quantity: 3 },
{ code: '126', name: 'Product 4', category: 'Category 4', quantity: 4 },
{ code: '127', name: 'Product 5', category: 'Category 5', quantity: 5 },
];
return (
<>
<div className="flex flex-col justify-center">
{user.avatar && (
<Image
alt="logo"
src={returnImageProxy(user.avatar)}
width={100}
height={100}
className="rounded-full mx-auto my-4"
/>
)}
<h1 className="text-center text-2xl my-2">{user.username}</h1>
<h2 className="text-center text-xl my-2">{user.pubkey}</h2>
<div className="flex flex-row w-1/2 mx-auto my-4 justify-between">
<Button label="Edit" className="p-button-raised text-[#f8f8ff]" />
<Button label="Delete" className="p-button-raised p-button-danger text-[#f8f8ff]" />
</div>
<div className="flex flex-col w-1/2 mx-auto my-4 justify-between items-center">
<h2>Subscription</h2>
<p>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 value={purchases} tableStyle={{ minWidth: '50rem' }}>
<Column field="code" header="Code"></Column>
<Column field="name" header="Name"></Column>
<Column field="category" header="Category"></Column>
<Column field="quantity" header="Quantity"></Column>
</DataTable>
</>
)
}
export default Profile

View File

@ -1,13 +1,13 @@
import { createSlice, current } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
// Helper function to add single or multiple items
const addItems = (state, action, key) => { const addItems = (state, action, key) => {
const items = current(state)[key];
if (Array.isArray(action.payload)) { if (Array.isArray(action.payload)) {
// If payload is an array, spread it into the existing array // If payload is an array, spread it into the existing array
state[key] = [...items, ...action.payload]; state[key] = [...state[key], ...action.payload];
} else { } else {
// If payload is a single item, push it into the array // If payload is a single item, push it into the array
items.push(action.payload); state[key].push(action.payload);
} }
}; };
@ -18,16 +18,22 @@ export const eventsSlice = createSlice({
courses: [], courses: [],
}, },
reducers: { reducers: {
setItems: (state, action) => { addResource: (state, action) => {
if (action.payload.type === 'resource') { addItems(state, action, 'resources');
addItems(state, action.payload.data, 'resources'); },
} else if (action.payload.type === 'course') { addCourse: (state, action) => {
addItems(state, action.payload.data, 'courses'); addItems(state, action, 'courses');
} },
// If you need to set the entire array at once, keep these or adjust as necessary
setResources: (state, action) => {
state.resources = action.payload;
},
setCourses: (state, action) => {
state.courses = action.payload;
}, },
}, },
}); });
// Exports // Exports
export const { setItems } = eventsSlice.actions; export const { addResource, addCourse, setResources, setCourses } = eventsSlice.actions;
export default eventsSlice.reducer; export default eventsSlice.reducer;

View File

@ -13,23 +13,19 @@ export const initialRelays = [
export const userSlice = createSlice({ export const userSlice = createSlice({
name: "user", name: "user",
initialState: { initialState: {
pubkey: '', user: {},
username: '',
relays: initialRelays, relays: initialRelays,
}, },
reducers: { reducers: {
setRelays: (state, action) => { setRelays: (state, action) => {
state.relays = [...state.relays, action.payload]; state.relays = [...state.relays, action.payload];
}, },
setPubkey: (state, action) => { setUser: (state, action) => {
state.pubkey = action.payload; state.user = action.payload;
},
setUsername: (state, action) => {
state.username = action.payload;
}, },
}, },
}); });
export const { setRelays, setPubkey, setUsername } = userSlice.actions; export const { setRelays, setUser } = userSlice.actions;
export default userSlice.reducer; export default userSlice.reducer;

View File

@ -1,4 +1,6 @@
export const findKind0Username = async (kind0) => { export const findKind0Fields = async (kind0) => {
let fields = {}
const usernameProperties = ['name', 'displayName', 'display_name', 'username', 'handle', 'alias']; const usernameProperties = ['name', 'displayName', 'display_name', 'username', 'handle', 'alias'];
const findTruthyPropertyValue = (object, properties) => { const findTruthyPropertyValue = (object, properties) => {
@ -12,5 +14,47 @@ export const findKind0Username = async (kind0) => {
const username = findTruthyPropertyValue(kind0, usernameProperties); const username = findTruthyPropertyValue(kind0, usernameProperties);
return username; if (username) {
} fields.username = username;
}
const avatar = findTruthyPropertyValue(kind0, ['picture', 'avatar', 'profilePicture', 'profile_picture']);
if (avatar) {
fields.avatar = avatar;
}
return fields;
}
export const parseResourceEvent = (event) => {
// Initialize an object to store the extracted data
const eventData = {
content: event.content || '',
title: '',
summary: '',
image: '',
published_at: '',
};
// Iterate over the tags array to extract data
event.tags.forEach(tag => {
switch (tag[0]) { // Check the key in each key-value pair
case 'title':
eventData.title = tag[1];
break;
case 'summary':
eventData.summary = tag[1];
break;
case 'image':
eventData.image = tag[1];
break;
case 'published_at':
eventData.published_at = tag[1];
break;
// Add cases for any other data you need to extract
}
});
return eventData;
};