Added example course/resource events on nostr, rendering course/resource

This commit is contained in:
austinkelsay 2024-02-27 18:29:57 -06:00
parent 34748412ee
commit 9ebf92e67e
14 changed files with 2083 additions and 491 deletions

2202
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,10 +19,13 @@
"primereact": "^10.2.1", "primereact": "^10.2.1",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-markdown": "^9.0.1",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"redux": "^5.0.1" "redux": "^5.0.1",
"rehype-raw": "^7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.11.21",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.0.1",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.0.4", "eslint-config-next": "14.0.4",

View File

@ -1,62 +1,49 @@
import React, { useState, useEffect } from 'react'; 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 { useRouter } from 'next/router';
import Image from 'next/image';
import { useSelector } from 'react-redux';
import { useImageProxy } from '@/hooks/useImageProxy';
import { parseEvent } from '@/utils/nostr';
import { formatTimestampToHowLongAgo } from '@/utils/time';
export default function BasicDemo() { export default function CoursesCarousel() {
const [courses, setCourses] = useState([ const courses = useSelector((state) => state.events.courses);
{ const [processedCourses, setProcessedCourses] = useState([]);
"title": "Lightning Wallet Frontend", const { returnImageProxy } = useImageProxy();
"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', const router = useRouter();
"price": 45000
}, useEffect(() => {
{ const processCourses = courses.map(course => {
"title": "Lightning Wallet Backend", const { id, content, title, summary, image, published_at } = parseEvent(course);
"description": "Learn Backend from scratch and build a simple Lightning Wallet backend with a server, API, Database, and Lightning node using NodeJS", return { id, content, title, summary, image, published_at };
"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
} }
]); );
setProcessedCourses(processCourses);
}, [courses]);
const productTemplate = (course) => { const courseTemplate = (course) => {
return ( return (
<div className="flex flex-col items-center w-full px-4"> <div onClick={() => router.push(`/course/${course.id}`)} className="flex flex-col items-center w-full px-4 cursor-pointer">
<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" /> <Image
alt="resource thumbnail"
src={returnImageProxy(course.image)}
width={344}
height={194}
className="w-full h-full object-cover object-center"
/>
</div> </div>
<div className='text-center'> <div className='flex flex-col justify-start w-[426px]'>
<h4 className="mb-1 text-center">{course.title}</h4> <h4 className="mb-1 font-bold text-xl">{course.title}</h4>
<h6 className="mt-0 mb-3 text-center">{course.price} sats</h6> <p className='truncate'>{course.summary}</p>
<div className="flex flex-row items-center justify-center gap-2"> <p className="text-sm mt-1 text-gray-400">Published: {formatTimestampToHowLongAgo(course.published_at)}</p>
{/* <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> */}
</div> </div>
</div> </div>
); );
@ -65,8 +52,7 @@ export default function BasicDemo() {
return ( return (
<> <>
<h1 className="text-2xl font-bold ml-[6%] my-4">Courses</h1> <h1 className="text-2xl font-bold ml-[6%] my-4">Courses</h1>
<Carousel value={courses} numVisible={3} itemTemplate={productTemplate} /> <Carousel value={processedCourses} numVisible={3} itemTemplate={courseTemplate} />
</> </>
) );
} }

View File

@ -26,7 +26,7 @@ const UserAvatar = () => {
let userAvatar; let userAvatar;
if (user) { if (user && Object.keys(user).length > 0) {
// User exists, show username or pubkey // User exists, show username or pubkey
const displayName = user.username || user.pubkey; const displayName = user.username || user.pubkey;

View File

@ -1,28 +1,31 @@
import React, { useState, useEffect } from 'react'; 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 { useRouter } from 'next/router';
import Image from 'next/image'; import Image from 'next/image';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useImageProxy } from '@/hooks/useImageProxy'; import { useImageProxy } from '@/hooks/useImageProxy';
import { parseResourceEvent } from '@/utils/nostr'; import { parseEvent } from '@/utils/nostr';
import { formatUnixTimestamp } from '@/utils/time'; import { formatTimestampToHowLongAgo } from '@/utils/time';
export default function ResourcesCarousel() { export default function ResourcesCarousel() {
const resources = useSelector((state) => state.events.resources); const resources = useSelector((state) => state.events.resources);
const [processedResources, setProcessedResources] = useState([]); const [processedResources, setProcessedResources] = useState([]);
const { returnImageProxy } = useImageProxy(); const { returnImageProxy } = useImageProxy();
const router = useRouter();
useEffect(() => { useEffect(() => {
const processResources = resources.map(resource => { const processResources = resources.map(resource => {
const { content, title, summary, image, published_at } = parseResourceEvent(resource); const { id, content, title, summary, image, published_at } = parseEvent(resource);
return { content, title, summary, image, published_at }; return { id, content, title, summary, image, published_at };
}); });
setProcessedResources(processResources); setProcessedResources(processResources);
}, [resources]); }, [resources]);
const resourceTemplate = (resource) => { const resourceTemplate = (resource) => {
return ( return (
<div className="flex flex-col items-center w-full px-4"> <div onClick={() => router.push(`/resource/${resource.id}`)} className="flex flex-col items-center w-full px-4 cursor-pointer">
<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">
<Image <Image
alt="resource thumbnail" alt="resource thumbnail"
@ -32,14 +35,14 @@ export default function ResourcesCarousel() {
className="w-full h-full object-cover object-center" className="w-full h-full object-cover object-center"
/> />
</div> </div>
<div className='text-center'> <div className='flex flex-col justify-start w-[426px]'>
<h4 className="mb-1 font-bold text-center">{resource.title}</h4> <h4 className="mb-1 font-bold text-xl">{resource.title}</h4>
<p className="text-center">{resource.summary}</p> <p className='truncate'>{resource.summary}</p>
<div className="flex flex-row items-center justify-center gap-2"> <p className="text-sm mt-1 text-gray-400">Published: {formatTimestampToHowLongAgo(resource.published_at)}</p>
{/* <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: {formatUnixTimestamp(resource.published_at)}</p>
</div> </div>
</div> </div>
); );

View File

@ -23,18 +23,20 @@ export const useLogin = () => {
try { try {
const response = await axios.get(`/api/users/${publicKey}`); const response = await axios.get(`/api/users/${publicKey}`);
console.log('auto login response:', response);
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
dispatch(setUser(response.data)); dispatch(setUser(response.data));
} else if (response.status === 204) { } else if (response.status === 204) {
// User not found, create a new user // User not found, create a new user
const kind0 = await fetchKind0([{ authors: [publicKey], kinds: [0] }], {}); const kind0 = await fetchKind0([{ authors: [publicKey], kinds: [0] }], {});
console.log('kind0:', kind0);
const fields = await findKind0Fields(kind0); const fields = await findKind0Fields(kind0);
const payload = { pubkey: publicKey, ...fields }; const payload = { pubkey: publicKey, ...fields };
try { try {
const createUserResponse = await axios.post(`/api/users`, payload); const createUserResponse = await axios.post(`/api/users`, payload);
console.log('create user response:', createUserResponse);
if (createUserResponse.status === 201) { if (createUserResponse.status === 201) {
;
window.localStorage.setItem('pubkey', publicKey); window.localStorage.setItem('pubkey', publicKey);
dispatch(setUser(createUserResponse.data)); dispatch(setUser(createUserResponse.data));
} else { } else {

View File

@ -1,7 +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 { addResource, addCourse } from "@/redux/reducers/eventsReducer";
import { initialRelays } from "@/redux/reducers/userReducer"; import { initialRelays } from "@/redux/reducers/userReducer";
export const useNostr = () => { export const useNostr = () => {
@ -67,14 +67,14 @@ export const useNostr = () => {
const params = {seenOnEnabled: true}; const params = {seenOnEnabled: true};
const hasPlebdevsTag = (eventData) => { const hasRequiredTags = (eventData) => {
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs"); return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "resource");
}; };
const sub = pool.current.subscribeMany(relays, filter, { const sub = pool.current.subscribeMany(relays, filter, {
...params, ...params,
onevent: (event) => { onevent: (event) => {
if (hasPlebdevsTag(event.tags)) { if (hasRequiredTags(event.tags)) {
dispatch(addResource(event)); dispatch(addResource(event));
} }
}, },
@ -88,17 +88,45 @@ export const useNostr = () => {
}); });
} }
const fetchCourses = async () => {
const filter = [{kinds: [30023], authors: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741"]}];
const params = {seenOnEnabled: true};
const hasRequiredTags = (eventData) => {
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "course");
};
const sub = pool.current.subscribeMany(relays, filter, {
...params,
onevent: (event) => {
if (hasRequiredTags(event.tags)) {
dispatch(addCourse(event));
}
},
onerror: (error) => {
console.error("Error fetching courses:", 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] }], {
onevent: (event) => {
sub.on("event", (event) => {
resolve(event); resolve(event);
}); },
onerror: (error) => {
sub.on("error", (error) => {
reject(error); reject(error);
},
oneose: () => {
console.log("Subscription closed");
sub.close();
}
}); });
}); });
}; };
@ -130,6 +158,7 @@ export const useNostr = () => {
publishEvent, publishEvent,
fetchKind0, fetchKind0,
fetchResources, fetchResources,
fetchCourses,
getRelayStatuses, getRelayStatuses,
}; };
}; };

View File

@ -0,0 +1,54 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useNostr } from "@/hooks/useNostr";
import { parseEvent } from "@/utils/nostr";
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
const MarkdownContent = ({ content }) => {
return (
<div>
<ReactMarkdown rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
</div>
);
};
const Course = () => {
const [course, setCourse] = useState(null);
const router = useRouter();
const { fetchSingleEvent } = useNostr();
const { slug } = router.query;
useEffect(() => {
const getCourse = async () => {
if (slug) {
const fetchedCourse = await fetchSingleEvent(slug);
const formattedCourse = parseEvent(fetchedCourse);
setCourse(formattedCourse);
}
};
if (slug && !course) {
getCourse();
}
}, [slug]);
return (
<div className="flex flex-col justify-center mx-12">
<h1 className="my-6 text-3xl text-center font-bold">{course?.title}</h1>
<h2 className="text-lg text-center whitespace-pre-line">{course?.summary}</h2>
<div className="mx-auto my-6">
{
course?.content && <MarkdownContent content={course?.content} />
}
</div>
</div>
);
}
export default Course;

View File

@ -5,12 +5,12 @@ import ResourcesCarousel from '@/components/resources/ResourcesCarousel'
import { useNostr } from '@/hooks/useNostr' import { useNostr } from '@/hooks/useNostr'
export default function Home() { export default function Home() {
const { fetchResources } = useNostr(); const { fetchResources, fetchCourses } = useNostr();
useEffect(() => { useEffect(() => {
console.log('Fetching resources');
fetchResources(); fetchResources();
}, [fetchResources]); fetchCourses();
}, [fetchResources, fetchCourses]);
return ( return (
<> <>

View File

@ -0,0 +1,58 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useNostr } from "@/hooks/useNostr";
import { parseEvent } from "@/utils/nostr";
import ReactMarkdown from 'react-markdown';
import rehypeRaw from 'rehype-raw';
const MarkdownContent = ({ content }) => {
return (
<div>
<ReactMarkdown rehypePlugins={[rehypeRaw]}>
{content}
</ReactMarkdown>
</div>
);
};
const Resource = () => {
const [resource, setResource] = useState(null);
const router = useRouter();
const { fetchSingleEvent } = useNostr();
const { slug } = router.query;
console.log('slug:', slug);
useEffect(() => {
const getResource = async () => {
if (slug) {
const fetchedResource = await fetchSingleEvent(slug);
console.log('fetchedResource:', fetchedResource);
const formattedResource = parseEvent(fetchedResource);
console.log('formattedResource:', formattedResource.summary);
setResource(formattedResource);
}
};
if (slug && !resource) {
getResource();
}
}, [slug]);
return (
<div className="flex flex-col justify-center mx-12">
<h1 className="my-6 text-3xl text-center font-bold">{resource?.title}</h1>
<h2 className="text-lg text-center whitespace-pre-line">{resource?.summary}</h2>
<div className="mx-auto my-6">
{
resource?.content && <MarkdownContent content={resource?.content} />
}
</div>
</div>
);
}
export default Resource;

View File

@ -1,14 +1,20 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
// Helper function to add single or multiple items // Helper function to add single or multiple items without duplicates
const addItems = (state, action, key) => { const addItems = (state, action, key) => {
const existingIds = new Set(state[key].map(item => item.id));
if (Array.isArray(action.payload)) { if (Array.isArray(action.payload)) {
// If payload is an array, spread it into the existing array // Filter out duplicates based on the id
state[key] = [...state[key], ...action.payload]; const uniqueItems = action.payload.filter(item => !existingIds.has(item.id));
// If payload is an array, spread it into the existing array without duplicates
state[key] = [...state[key], ...uniqueItems];
} else { } else {
// If payload is a single item, push it into the array // If payload is a single item, push it into the array if it's not a duplicate
if (!existingIds.has(action.payload.id)) {
state[key].push(action.payload); state[key].push(action.payload);
} }
}
}; };
export const eventsSlice = createSlice({ export const eventsSlice = createSlice({
@ -24,7 +30,6 @@ export const eventsSlice = createSlice({
addCourse: (state, action) => { addCourse: (state, action) => {
addItems(state, action, 'courses'); addItems(state, action, 'courses');
}, },
// If you need to set the entire array at once, keep these or adjust as necessary
setResources: (state, action) => { setResources: (state, action) => {
state.resources = action.payload; state.resources = action.payload;
}, },
@ -34,6 +39,5 @@ export const eventsSlice = createSlice({
}, },
}); });
// Exports
export const { addResource, addCourse, setResources, setCourses } = eventsSlice.actions; export const { addResource, addCourse, setResources, setCourses } = eventsSlice.actions;
export default eventsSlice.reducer; export default eventsSlice.reducer;

View File

@ -27,9 +27,10 @@ export const findKind0Fields = async (kind0) => {
return fields; return fields;
} }
export const parseResourceEvent = (event) => { export const parseEvent = (event) => {
// Initialize an object to store the extracted data // Initialize an object to store the extracted data
const eventData = { const eventData = {
id: event.id,
content: event.content || '', content: event.content || '',
title: '', title: '',
summary: '', summary: '',

View File

@ -2,3 +2,29 @@ export const formatUnixTimestamp = (time) => {
const date = new Date(time * 1000); // Convert to milliseconds const date = new Date(time * 1000); // Convert to milliseconds
return date.toDateString(); return date.toDateString();
} }
export const formatTimestampToHowLongAgo = (time) => {
const date = new Date(time * 1000);
const now = new Date();
const diff = now - date;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
if (years > 0) {
return `${years} year${years > 1 ? 's' : ''} ago`;
} else if (months > 0) {
return `${months} month${months > 1 ? 's' : ''} ago`;
} else if (days > 0) {
return `${days} day${days > 1 ? 's' : ''} ago`;
} else if (hours > 0) {
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
} else if (minutes > 0) {
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
} else {
return `${seconds} second${seconds > 1 ? 's' : ''} ago`;
}
}

28
tsconfig.json Normal file
View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}