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

2200
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,72 +1,58 @@
import React, { useState, useEffect } from 'react';
import { Button } from 'primereact/button';
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() {
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
export default function CoursesCarousel() {
const courses = useSelector((state) => state.events.courses);
const [processedCourses, setProcessedCourses] = useState([]);
const { returnImageProxy } = useImageProxy();
const router = useRouter();
useEffect(() => {
const processCourses = courses.map(course => {
const { id, content, title, summary, image, published_at } = parseEvent(course);
return { id, content, title, summary, image, published_at };
}
]);
);
setProcessedCourses(processCourses);
}, [courses]);
const productTemplate = (course) => {
const courseTemplate = (course) => {
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">
<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 className='text-center'>
<h4 className="mb-1 text-center">{course.title}</h4>
<h6 className="mt-0 mb-3 text-center">{course.price} sats</h6>
<div className="flex flex-row items-center justify-center gap-2">
<div className='flex flex-col justify-start w-[426px]'>
<h4 className="mb-1 font-bold text-xl">{course.title}</h4>
<p className='truncate'>{course.summary}</p>
<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-star-fill" rounded severity="success" />
</div>
</div> */}
</div>
</div>
);
};
};
return (
<>
<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

@ -1,4 +1,4 @@
import React, {useRef} from 'react';
import React, { useRef } from 'react';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useImageProxy } from '@/hooks/useImageProxy';
@ -26,7 +26,7 @@ const UserAvatar = () => {
let userAvatar;
if (user) {
if (user && Object.keys(user).length > 0) {
// User exists, show username or pubkey
const displayName = user.username || user.pubkey;

View File

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

View File

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

View File

@ -1,7 +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 { addResource, addCourse } from "@/redux/reducers/eventsReducer";
import { initialRelays } from "@/redux/reducers/userReducer";
export const useNostr = () => {
@ -67,14 +67,14 @@ export const useNostr = () => {
const params = {seenOnEnabled: true};
const hasPlebdevsTag = (eventData) => {
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
const hasRequiredTags = (eventData) => {
return eventData.some(([tag, value]) => tag === "t" && value === "plebdevs") && eventData.some(([tag, value]) => tag === "t" && value === "resource");
};
const sub = pool.current.subscribeMany(relays, filter, {
...params,
onevent: (event) => {
if (hasPlebdevsTag(event.tags)) {
if (hasRequiredTags(event.tags)) {
dispatch(addResource(event));
}
},
@ -87,18 +87,46 @@ 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) => {
return new Promise((resolve, reject) => {
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }]);
sub.on("event", (event) => {
resolve(event);
});
sub.on("error", (error) => {
reject(error);
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }], {
onevent: (event) => {
resolve(event);
},
onerror: (error) => {
reject(error);
},
oneose: () => {
console.log("Subscription closed");
sub.close();
}
});
});
};
@ -130,6 +158,7 @@ export const useNostr = () => {
publishEvent,
fetchKind0,
fetchResources,
fetchCourses,
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'
export default function Home() {
const { fetchResources } = useNostr();
const { fetchResources, fetchCourses } = useNostr();
useEffect(() => {
console.log('Fetching resources');
fetchResources();
}, [fetchResources]);
fetchCourses();
}, [fetchResources, fetchCourses]);
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,13 +1,19 @@
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 existingIds = new Set(state[key].map(item => item.id));
if (Array.isArray(action.payload)) {
// If payload is an array, spread it into the existing array
state[key] = [...state[key], ...action.payload];
// Filter out duplicates based on the id
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 {
// If payload is a single item, push it into the array
state[key].push(action.payload);
// 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);
}
}
};
@ -24,7 +30,6 @@ export const eventsSlice = createSlice({
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;
},
@ -34,6 +39,5 @@ export const eventsSlice = createSlice({
},
});
// Exports
export const { addResource, addCourse, setResources, setCourses } = eventsSlice.actions;
export default eventsSlice.reducer;

View File

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

View File

@ -2,3 +2,29 @@ export const formatUnixTimestamp = (time) => {
const date = new Date(time * 1000); // Convert to milliseconds
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"
]
}