From b27384c8a27e969cd64cfaf210363c4d0d50eb5a Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Fri, 13 Sep 2024 18:52:33 -0500 Subject: [PATCH] searchbar is functional with content and community --- .../carousels/newTemplates/CourseTemplate.js | 2 +- .../carousels/templates/CourseTemplate.js | 2 +- .../content/dropdowns/ContentDropdownItem.js | 17 +++- .../content/dropdowns/MessageDropdownItem.js | 90 +++++++++++++++++++ .../feeds/messages/CommunityMessage.js | 6 +- src/components/navbar/Navbar.js | 4 +- src/components/navbar/user/UserAvatar.js | 8 +- src/components/search/SearchBar.js | 85 ++++++++++++++++-- src/hooks/useCommunitySearch.js | 49 ++++++++++ src/hooks/useContentSearch.js | 62 +++++++++++++ src/hooks/useDebounce.js | 17 ++++ src/utils/nostr.js | 21 +++++ 12 files changed, 344 insertions(+), 19 deletions(-) create mode 100644 src/components/content/dropdowns/MessageDropdownItem.js create mode 100644 src/hooks/useCommunitySearch.js create mode 100644 src/hooks/useContentSearch.js create mode 100644 src/hooks/useDebounce.js diff --git a/src/components/content/carousels/newTemplates/CourseTemplate.js b/src/components/content/carousels/newTemplates/CourseTemplate.js index d74ad76..01fd8eb 100644 --- a/src/components/content/carousels/newTemplates/CourseTemplate.js +++ b/src/components/content/carousels/newTemplates/CourseTemplate.js @@ -59,7 +59,7 @@ export function CourseTemplate({ course }) {
- {course.topics.map((topic, index) => ( + {course && course.topics && course.topics.map((topic, index) => ( {topic} diff --git a/src/components/content/carousels/templates/CourseTemplate.js b/src/components/content/carousels/templates/CourseTemplate.js index 8ef1c9c..82ee2ed 100644 --- a/src/components/content/carousels/templates/CourseTemplate.js +++ b/src/components/content/carousels/templates/CourseTemplate.js @@ -68,7 +68,7 @@ const CourseTemplate = ({ course }) => {
{course?.topics && course?.topics.length > 0 && (
- {course.topics.map((topic, index) => ( + {course && course.topics && course.topics.map((topic, index) => ( ))}
diff --git a/src/components/content/dropdowns/ContentDropdownItem.js b/src/components/content/dropdowns/ContentDropdownItem.js index 022543f..87899ed 100644 --- a/src/components/content/dropdowns/ContentDropdownItem.js +++ b/src/components/content/dropdowns/ContentDropdownItem.js @@ -2,6 +2,7 @@ import React, {useEffect} from "react"; import Image from "next/image"; import { useImageProxy } from "@/hooks/useImageProxy"; import { formatUnixTimestamp } from "@/utils/time"; +import { Tag } from "primereact/tag"; import GenericButton from "@/components/buttons/GenericButton"; const ContentDropdownItem = ({ content, onSelect }) => { @@ -18,14 +19,22 @@ const ContentDropdownItem = ({ content, onSelect }) => { className="w-[100px] h-[100px] object-cover object-center border-round" />
-
{content.title}
-
{content.summary}
+
{content.title || content.name}
+
{content.summary || content.description}
+ {content.price &&
Price: {content.price}
} + {content?.topics?.length > 0 && ( +
+ {content.topics.map((topic) => ( + + ))} +
+ )}
- {content.published_at ? `Published: ${formatUnixTimestamp(content.published_at)}` : "not yet published"} + {(content.published_at || content.created_at) ? `Published: ${formatUnixTimestamp(content.published_at || content.created_at)}` : "not yet published"}
- onSelect(content)} className="text-[#f8f8ff]" /> + onSelect(content)} className="py-2" />
diff --git a/src/components/content/dropdowns/MessageDropdownItem.js b/src/components/content/dropdowns/MessageDropdownItem.js new file mode 100644 index 0000000..462e3ef --- /dev/null +++ b/src/components/content/dropdowns/MessageDropdownItem.js @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from "react"; +import CommunityMessage from "@/components/feeds/messages/CommunityMessage"; +import { parseMessageEvent, findKind0Fields } from "@/utils/nostr"; +import { useNDKContext } from "@/context/NDKContext"; + +const MessageDropdownItem = ({ message, onSelect }) => { + const { ndk, addSigner } = useNDKContext(); + const [author, setAuthor] = useState(null); + const [formattedMessage, setFormattedMessage] = useState(null); + const [messageWithAuthor, setMessageWithAuthor] = useState(null); + const [loading, setLoading] = useState(true); + const [platform, setPlatform] = useState(null); + + const determinePlatform = () => { + if (message?.channel) { + return "discord"; + } else if (message?.kind) { + return "nostr"; + } else { + return "stackernews"; + } + } + + useEffect(() => { + setPlatform(determinePlatform(message)); + }, [message]); + + useEffect(() => { + if (platform === "nostr") { + const event = parseMessageEvent(message); + setFormattedMessage(event); + + const getAuthor = async () => { + try { + await ndk.connect(); + const author = await ndk.getUser({ pubkey: message.pubkey }); + if (author && author?.content) { + const authorFields = findKind0Fields(JSON.parse(author.content)); + console.log("authorFields", authorFields); + if (authorFields) { + setAuthor(authorFields); + } + } else if (author?._pubkey) { + setAuthor(author?._pubkey); + } + } catch (error) { + console.error(error); + } + } + getAuthor(); + } else if (platform === "stackernews") { + setMessageWithAuthor({ + content: message?.title, + author: message?.user?.name, + timestamp: message?.created_at ? Date.parse(message.created_at) : null, + avatar: "https://pbs.twimg.com/profile_images/1403162883941359619/oca7LMQ2_400x400.png", + channel: "~devs", + ...message + }) + } + else { + setLoading(false); + } + }, [ndk, message, platform]); + + useEffect(() => { + if (author && formattedMessage && platform === 'nostr') { + const body = { + ...formattedMessage, + timestamp: formattedMessage.created_at || message.created_at || message.timestamp, + channel: "plebdevs", + author: author + }; + setMessageWithAuthor(body); + setLoading(false); + } + }, [author, formattedMessage, message, platform]); + + return ( +
+ {loading ? ( +
Loading...
+ ) : ( + + )} +
+ ); +}; + +export default MessageDropdownItem; \ No newline at end of file diff --git a/src/components/feeds/messages/CommunityMessage.js b/src/components/feeds/messages/CommunityMessage.js index 51d45ea..a8fe645 100644 --- a/src/components/feeds/messages/CommunityMessage.js +++ b/src/components/feeds/messages/CommunityMessage.js @@ -50,7 +50,7 @@ const CommunityMessage = ({ message, searchQuery, windowWidth, platform }) => {
-

{message.author}

+

{message?.pubkey ? (message?.pubkey.slice(0, 12) + "...") : message.author}

@@ -90,9 +90,11 @@ const CommunityMessage = ({ message, searchQuery, windowWidth, platform }) => { ) : (
-

+ {platform !== "nostr" ? ( +

{new Date(message.timestamp).toLocaleString()}

+ ) :
} 768 ? `View in ${platform}` : null} icon="pi pi-external-link" diff --git a/src/components/navbar/Navbar.js b/src/components/navbar/Navbar.js index 5d54a81..09a5944 100644 --- a/src/components/navbar/Navbar.js +++ b/src/components/navbar/Navbar.js @@ -6,10 +6,12 @@ import { useRouter } from 'next/router'; import SearchBar from '../search/SearchBar'; import 'primereact/resources/primereact.min.css'; import 'primeicons/primeicons.css'; +import { useNDKContext } from '@/context/NDKContext'; const Navbar = () => { const router = useRouter(); const navbarHeight = '60px'; + const {ndk} = useNDKContext(); const start = (
@@ -23,7 +25,7 @@ const Navbar = () => { />

PlebDevs

- + {ndk && }
); diff --git a/src/components/navbar/user/UserAvatar.js b/src/components/navbar/user/UserAvatar.js index 5abca9d..033c790 100644 --- a/src/components/navbar/user/UserAvatar.js +++ b/src/components/navbar/user/UserAvatar.js @@ -86,10 +86,10 @@ const UserAvatar = () => { <>
router.push('/about')} size={windowWidth < 768 ? 'small' : 'normal'} /> @@ -114,10 +114,10 @@ const UserAvatar = () => { userAvatar = (
router.push('/about')} size={windowWidth < 768 ? 'small' : 'normal'} /> diff --git a/src/components/search/SearchBar.js b/src/components/search/SearchBar.js index bb64286..709c575 100644 --- a/src/components/search/SearchBar.js +++ b/src/components/search/SearchBar.js @@ -1,16 +1,28 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { InputText } from 'primereact/inputtext'; import { InputIcon } from 'primereact/inputicon'; import { IconField } from 'primereact/iconfield'; import { Dropdown } from 'primereact/dropdown'; +import { OverlayPanel } from 'primereact/overlaypanel'; +import ContentDropdownItem from '@/components/content/dropdowns/ContentDropdownItem'; +import MessageDropdownItem from '@/components/content/dropdowns/MessageDropdownItem'; +import { useContentSearch } from '@/hooks/useContentSearch'; +import { useCommunitySearch } from '@/hooks/useCommunitySearch'; +import { useRouter } from 'next/router'; import styles from './searchbar.module.css'; const SearchBar = () => { + const { searchContent, searchResults: contentResults } = useContentSearch(); + const { searchCommunity, searchResults: communityResults } = useCommunitySearch(); + const router = useRouter(); const [selectedSearchOption, setSelectedSearchOption] = useState({ name: 'Content', code: 'content', icon: 'pi pi-video' }); const searchOptions = [ { name: 'Content', code: 'content', icon: 'pi pi-video' }, { name: 'Community', code: 'community', icon: 'pi pi-users' }, ]; + const [searchTerm, setSearchTerm] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const op = useRef(null); const selectedOptionTemplate = (option, props) => { if (!props?.placeholder) { @@ -24,15 +36,55 @@ const SearchBar = () => { return }; + const handleSearch = (e) => { + const term = e.target.value; + setSearchTerm(term); + + if (selectedSearchOption.code === 'content') { + searchContent(term); + setSearchResults(contentResults); + } else if (selectedSearchOption.code === 'community') { + searchCommunity(term); + setSearchResults(communityResults); + } + + if (term.length > 2) { + op.current.show(e); + } else { + op.current.hide(); + } + }; + + useEffect(() => { + if (selectedSearchOption.code === 'content') { + setSearchResults(contentResults); + } else if (selectedSearchOption.code === 'community') { + setSearchResults(communityResults); + } + }, [selectedSearchOption, contentResults, communityResults]); + + const handleContentSelect = (content) => { + router.push(`/details/${content.id}`); + setSearchTerm(''); + searchContent(''); + op.current.hide(); + } + return (
- + { required /> + + + {searchResults.map((item, index) => ( + item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? ( + + ) : ( + + ) + ))} + {searchResults.length === 0 && searchTerm.length > 2 && ( +
No results found
+ )} +
); }; diff --git a/src/hooks/useCommunitySearch.js b/src/hooks/useCommunitySearch.js new file mode 100644 index 0000000..22d26ec --- /dev/null +++ b/src/hooks/useCommunitySearch.js @@ -0,0 +1,49 @@ +import { useState, useEffect } from 'react'; +import { useDiscordQuery } from '@/hooks/communityQueries/useDiscordQuery'; +import { useCommunityNotes } from '@/hooks/nostr/useCommunityNotes'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +const fetchStackerNews = async () => { + const response = await axios.get('/api/stackernews'); + return response.data.data.items.items; +}; + +export const useCommunitySearch = () => { + const [searchResults, setSearchResults] = useState([]); + const { data: discordData } = useDiscordQuery({ page: 1 }); + const { communityNotes: nostrData } = useCommunityNotes(); + const { data: stackerNewsData } = useQuery({ queryKey: ['stackerNews'], queryFn: fetchStackerNews }); + + const searchCommunity = (term) => { + if (term.length < 3) { + setSearchResults([]); + return; + } + + const lowercaseTerm = term.toLowerCase(); + + const filteredDiscord = (discordData || []) + .filter(message => message.content.toLowerCase().includes(lowercaseTerm)) + .map(message => ({ ...message, type: 'discord' })); + + const filteredNostr = (nostrData || []) + .filter(message => message.content.toLowerCase().includes(lowercaseTerm)) + .map(message => ({ ...message, type: 'nostr' })); + + const filteredStackerNews = (stackerNewsData || []) + .filter(item => item.title.toLowerCase().includes(lowercaseTerm)) + .map(item => ({ ...item, type: 'stackernews' })); + + const combinedResults = [...filteredDiscord, ...filteredNostr, ...filteredStackerNews] + .sort((a, b) => { + const dateA = a.type === 'nostr' ? a.created_at * 1000 : new Date(a.timestamp || a.createdAt); + const dateB = b.type === 'nostr' ? b.created_at * 1000 : new Date(b.timestamp || b.createdAt); + return dateB - dateA; + }); + + setSearchResults(combinedResults); + }; + + return { searchCommunity, searchResults }; +}; \ No newline at end of file diff --git a/src/hooks/useContentSearch.js b/src/hooks/useContentSearch.js new file mode 100644 index 0000000..114d13b --- /dev/null +++ b/src/hooks/useContentSearch.js @@ -0,0 +1,62 @@ +import { useState, useEffect } from 'react'; +import { useContentIdsQuery } from '@/hooks/apiQueries/useContentIdsQuery'; +import { useNDKContext } from '@/context/NDKContext'; +import { parseEvent, parseCourseEvent } from '@/utils/nostr'; + +const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY; + +export const useContentSearch = () => { + const [allContent, setAllContent] = useState([]); + const [searchResults, setSearchResults] = useState([]); + const { contentIds } = useContentIdsQuery(); + const { ndk } = useNDKContext(); + + const fetchAllEvents = async (ids) => { + try { + await ndk.connect(); + const filter = { + authors: [AUTHOR_PUBKEY], + kinds: [30004, 30023, 30402], + "#d": ids + } + const events = await ndk.fetchEvents(filter); + + const parsedEvents = new Set(); + events.forEach((event) => { + let parsed; + if (event.kind === 30004) { + parsed = parseCourseEvent(event); + } else { + parsed = parseEvent(event); + } + parsedEvents.add(parsed); + }); + setAllContent(parsedEvents); + } catch (error) { + console.log('error', error) + } + } + + useEffect(() => { + if (contentIds) { + fetchAllEvents(contentIds); + } + }, [contentIds]); + + const searchContent = (term) => { + if (term.length > 2) { + const filtered = Array.from(allContent).filter(content => { + const searchableTitle = (content?.title || content?.name || '').toLowerCase(); + const searchableDescription = (content?.summary || content?.description || '').toLowerCase(); + const searchTerm = term.toLowerCase(); + + return searchableTitle.includes(searchTerm) || searchableDescription.includes(searchTerm); + }); + setSearchResults(filtered); + } else { + setSearchResults([]); + } + }; + + return { searchContent, searchResults }; +}; \ No newline at end of file diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js new file mode 100644 index 0000000..e82ff90 --- /dev/null +++ b/src/hooks/useDebounce.js @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value, delay) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} \ No newline at end of file diff --git a/src/utils/nostr.js b/src/utils/nostr.js index 9d647ce..2473cf0 100644 --- a/src/utils/nostr.js +++ b/src/utils/nostr.js @@ -5,6 +5,8 @@ export const findKind0Fields = async (kind0) => { const usernameProperties = ['name', 'displayName', 'display_name', 'username', 'handle', 'alias']; + const pubkeyProperties = ['pubkey', 'npub', '_pubkey']; + const findTruthyPropertyValue = (object, properties) => { for (const property of properties) { if (object?.[property]) { @@ -26,9 +28,28 @@ export const findKind0Fields = async (kind0) => { fields.avatar = avatar; } + const pubkey = findTruthyPropertyValue(kind0, pubkeyProperties); + + if (pubkey) { + fields.pubkey = pubkey; + } + return fields; } +export const parseMessageEvent = (event) => { + const eventData = { + id: event.id, + pubkey: event.pubkey || '', + content: event.content || '', + kind: event.kind || '', + type: 'message', + }; + + return eventData; +} + + export const parseEvent = (event) => { // Initialize an object to store the extracted data const eventData = {