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 = {
reactStrictMode: true,
images: {
domains: ['fakestoreapi.com'],
domains: ['localhost'],
},
}

View File

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

View File

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

View File

@ -1,32 +1,17 @@
import React from 'react';
import React, {useRef} from 'react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { Button } from 'primereact/button';
import UserAvatar from './user/UserAvatar';
import { Menubar } from 'primereact/menubar';
import { useSelector } from 'react-redux';
import { useRouter } from 'next/router';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import styles from './navbar.module.css';
const Navbar = () => {
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 = (
<div className={styles.titleContainer}>
<div onClick={() => router.push('/')} className={styles.titleContainer}>
<Image
alt="logo"
src="/plebdevs-guy.jpg"
@ -39,7 +24,7 @@ const Navbar = () => {
);
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;
padding-left: 6%;
padding-right: 6%;
cursor: pointer;
}
.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 { Carousel } from 'primereact/carousel';
import { Tag } from 'primereact/tag';
import { useSelector } from 'react-redux';
import { parseResourceEvent } from '@/utils/nostr';
export default function ResourcesCarousel() {
const [courses, setCourses] = useState([
{
"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 resources = useSelector((state) => state.events.resources);
const productTemplate = (course) => {
console.log('Resources:', resources);
const resourceTemplate = (resource) => {
const { content, title, summary, image, published_at } = parseResourceEvent(resource);
return (
<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">
<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 className='text-center'>
<h4 className="mb-1 text-center">{course.title}</h4>
<h6 className="mt-0 mb-3 text-center">{course.price} sats</h6>
<h4 className="mb-1 font-bold text-center">{title}</h4>
<p className="text-center">{summary}</p>
<div className="flex flex-row items-center justify-center gap-2">
<Button icon="pi pi-search" rounded />
<Button icon="pi pi-star-fill" rounded severity="success" />
</div>
<p className="text-center mt-2">Published on {published_at}</p>
</div>
</div>
);
@ -65,7 +34,7 @@ export default function ResourcesCarousel() {
return (
<>
<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 { useRouter } from 'next/router';
import { useDispatch } from 'react-redux';
import { useNostr } from './useNostr';
import axios from 'axios';
import { setPubkey, setUsername } from "@/redux/reducers/userReducer";
import { setUser } from "@/redux/reducers/userReducer";
import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { findKind0Username } from "@/utils/nostr";
import { findKind0Fields } from "@/utils/nostr";
import { useToast } from './useToast';
export const useLogin = () => {
const dispatch = useDispatch();
const router = useRouter();
const { showToast } = useToast();
const { fetchKind0 } = useNostr();
// Attempt Auto Login on render
useEffect(() => {
@ -22,11 +24,26 @@ export const useLogin = () => {
try {
const response = await axios.get(`/api/users/${publicKey}`);
if (response.status === 200 && response.data) {
dispatch(setPubkey(publicKey));
if (response.data.username) {
dispatch(setUsername(response.data.username));
dispatch(setUser(response.data));
} else if (response.status === 204) {
// 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) {
console.error('Error during auto login:', error);
@ -52,22 +69,21 @@ export const useLogin = () => {
const response = await axios.get(`/api/users/${publicKey}`);
if (response.status !== 200) throw new Error('User not found');
dispatch(setPubkey(publicKey));
window.localStorage.setItem('pubkey', publicKey);
if (response.data.username) dispatch(setUsername(response.data.username));
dispatch(setUser(response.data));
router.push('/');
} catch (error) {
// User not found, create a new user
const kind0 = await findKind0Username({ authors: [publicKey], kinds: [0] }); // Adjust based on actual implementation
const username = kind0 ? kind0 : undefined;
const payload = { pubkey: publicKey, ...(username && { username }) };
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) {
dispatch(setPubkey(publicKey));
;
window.localStorage.setItem('pubkey', publicKey);
if (username) dispatch(setUsername(username));
dispatch(setUser(createUserResponse.data));
router.push('/');
} else {
console.error('Error creating user:', createUserResponse);
@ -77,14 +93,14 @@ export const useLogin = () => {
showToast('error', 'Error Creating User', 'Failed to create user');
}
}
}, [dispatch, router, showToast]);
}, [dispatch, router, showToast, fetchKind0]);
const anonymousLogin = useCallback(() => {
try {
const secretKey = generateSecretKey();
const publicKey = getPublicKey(secretKey);
dispatch(setPubkey(publicKey));
dispatch(setUser({ pubkey: publicKey }));
window.localStorage.setItem('pubkey', publicKey);
window.localStorage.setItem('seckey', secretKey);
router.push('/');

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { SimplePool, relayInit, nip19 } from "nostr-tools";
import { useDispatch } from "react-redux";
import { addResource } from "@/redux/reducers/eventsReducer";
import { initialRelays } from "@/redux/reducers/userReducer";
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) => {
return new Promise((resolve, reject) => {
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }]);
@ -101,6 +132,7 @@ export const useNostr = () => {
fetchSingleEvent,
publishEvent,
fetchKind0,
fetchResources,
getRelayStatuses,
};
};

View File

@ -3,7 +3,7 @@ import { Provider } from "react-redux";
import { store } from "@/redux/store";
import Navbar from '@/components/navbar/Navbar';
import { ToastProvider } from '@/hooks/useToast';
import Layout from '@/components/layout';
import Layout from '@/components/Layout';
import '@/styles/globals.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 React, {useCallback, useEffect, useState} from 'react';
import CoursesCarousel from '@/components/courses/CoursesCarousel'
import ResourcesCarousel from '@/components/resources/ResourcesCarousel'
import { useNostr } from '@/hooks/useNostr'
export default function Home() {
const { fetchResources } = useNostr();
useEffect(() => {
console.log('Fetching resources');
fetchResources();
}, [fetchResources]);
return (
<>
<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 items = current(state)[key];
if (Array.isArray(action.payload)) {
// If payload is an array, spread it into the existing array
state[key] = [...items, ...action.payload];
state[key] = [...state[key], ...action.payload];
} else {
// 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: [],
},
reducers: {
setItems: (state, action) => {
if (action.payload.type === 'resource') {
addItems(state, action.payload.data, 'resources');
} else if (action.payload.type === 'course') {
addItems(state, action.payload.data, 'courses');
}
addResource: (state, action) => {
addItems(state, action, 'resources');
},
addCourse: (state, action) => {
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
export const { setItems } = eventsSlice.actions;
export const { addResource, addCourse, setResources, setCourses } = eventsSlice.actions;
export default eventsSlice.reducer;

View File

@ -13,23 +13,19 @@ export const initialRelays = [
export const userSlice = createSlice({
name: "user",
initialState: {
pubkey: '',
username: '',
user: {},
relays: initialRelays,
},
reducers: {
setRelays: (state, action) => {
state.relays = [...state.relays, action.payload];
},
setPubkey: (state, action) => {
state.pubkey = action.payload;
},
setUsername: (state, action) => {
state.username = action.payload;
setUser: (state, action) => {
state.user = action.payload;
},
},
});
export const { setRelays, setPubkey, setUsername } = userSlice.actions;
export const { setRelays, setUser } = userSlice.actions;
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 findTruthyPropertyValue = (object, properties) => {
@ -12,5 +14,47 @@ export const findKind0Username = async (kind0) => {
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;
};