searchbar is functional with content and community

This commit is contained in:
austinkelsay 2024-09-13 18:52:33 -05:00
parent 0619763786
commit b27384c8a2
12 changed files with 344 additions and 19 deletions

View File

@ -59,7 +59,7 @@ export function CourseTemplate({ course }) {
</div>
<CardContent className="pt-6 pb-2 w-full flex flex-row justify-between items-center">
<div className="flex flex-wrap gap-2">
{course.topics.map((topic, index) => (
{course && course.topics && course.topics.map((topic, index) => (
<Tag key={index} className="px-3 py-1 text-sm text-[#f8f8ff]">
{topic}
</Tag>

View File

@ -68,7 +68,7 @@ const CourseTemplate = ({ course }) => {
</div>
{course?.topics && course?.topics.length > 0 && (
<div className="flex flex-row justify-start items-center mt-2">
{course.topics.map((topic, index) => (
{course && course.topics && course.topics.map((topic, index) => (
<Tag key={index} value={topic} className="mr-2 text-white" />
))}
</div>

View File

@ -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"
/>
<div className="flex-1 max-w-[80vw]">
<div className="text-lg text-900 font-bold">{content.title}</div>
<div className="w-full text-sm text-600 text-wrap">{content.summary}</div>
<div className="text-lg text-900 font-bold">{content.title || content.name}</div>
<div className="w-full text-sm text-600 text-wrap line-clamp-2">{content.summary || content.description}</div>
{content.price && <div className="text-sm pt-6 text-gray-500">Price: {content.price}</div>}
{content?.topics?.length > 0 && (
<div className="text-sm pt-6 text-gray-500">
{content.topics.map((topic) => (
<Tag key={topic} value={topic} size="small" className="mr-2 text-[#f8f8ff]" />
))}
</div>
)}
<div className="text-sm pt-6 text-gray-500">
{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"}
</div>
</div>
<div className="flex flex-col justify-end">
<GenericButton label="Select" onClick={() => onSelect(content)} className="text-[#f8f8ff]" />
<GenericButton outlined size="small" label="Select" onClick={() => onSelect(content)} className="py-2" />
</div>
</div>
</div>

View File

@ -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 (
<div className="w-full border-t-2 border-gray-700 py-4">
{loading ? (
<div>Loading...</div>
) : (
<CommunityMessage message={messageWithAuthor ? messageWithAuthor : message} platform={platform} />
)}
</div>
);
};
export default MessageDropdownItem;

View File

@ -50,7 +50,7 @@ const CommunityMessage = ({ message, searchQuery, windowWidth, platform }) => {
<div className="flex flex-row w-full items-center justify-between p-4 bg-gray-800 rounded-t-lg">
<div className="flex flex-row items-center">
<Avatar image={message.avatar} shape="circle" size="large" className="border-2 border-blue-400" />
<p className="pl-4 font-bold text-xl text-white">{message.author}</p>
<p className="pl-4 font-bold text-xl text-white">{message?.pubkey ? (message?.pubkey.slice(0, 12) + "...") : message.author}</p>
</div>
<div className="flex flex-col items-start justify-between">
<div className="flex flex-row w-full justify-between items-center my-1 max-sidebar:flex-col max-sidebar:items-start">
@ -90,9 +90,11 @@ const CommunityMessage = ({ message, searchQuery, windowWidth, platform }) => {
</Panel>
) : (
<div className="w-full flex flex-row justify-between items-end">
<p className="rounded-lg text-sm text-gray-300">
{platform !== "nostr" ? (
<p className="rounded-lg text-sm text-gray-300">
{new Date(message.timestamp).toLocaleString()}
</p>
) : <div></div>}
<GenericButton
label={windowWidth > 768 ? `View in ${platform}` : null}
icon="pi pi-external-link"

View File

@ -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 = (
<div className='flex items-center'>
@ -23,7 +25,7 @@ const Navbar = () => {
/>
<h1 className="text-white text-xl font-semibold max-tab:text-2xl max-mob:text-2xl">PlebDevs</h1>
</div>
<SearchBar />
{ndk && <SearchBar />}
</div>
);

View File

@ -86,10 +86,10 @@ const UserAvatar = () => {
<>
<div className='flex flex-row items-center justify-between'>
<GenericButton
severity='help'
outlined
rounded
label="About"
className='text-[#f8f8ff] mr-4'
className='mr-4'
onClick={() => router.push('/about')}
size={windowWidth < 768 ? 'small' : 'normal'}
/>
@ -114,10 +114,10 @@ const UserAvatar = () => {
userAvatar = (
<div className='flex flex-row items-center justify-between'>
<GenericButton
severity='help'
outlined
rounded
label="About"
className='text-[#f8f8ff] mr-4'
className='mr-4'
onClick={() => router.push('/about')}
size={windowWidth < 768 ? 'small' : 'normal'}
/>

View File

@ -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 <i className={option.icon + ' text-transparent text-xs'} />
};
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 (
<div className='absolute left-[50%] transform -translate-x-[50%]'>
<IconField iconPosition="left">
<InputIcon className="pi pi-search"> </InputIcon>
<InputText className='w-[300px]' v-model="value1" placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`} pt={{
root: {
className: 'border-none rounded-tr-none rounded-br-none focus:border-none focus:ring-0 pr-0'
}
}} />
<InputText
className='w-[300px]'
value={searchTerm}
onChange={handleSearch}
placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
pt={{
root: {
className: 'border-none rounded-tr-none rounded-br-none focus:border-none focus:ring-0 pr-0'
}
}}
/>
<Dropdown
pt={{
@ -60,6 +112,27 @@ const SearchBar = () => {
required
/>
</IconField>
<OverlayPanel ref={op} className="w-[600px] max-h-[70vh] overflow-y-auto">
{searchResults.map((item, index) => (
item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
<MessageDropdownItem
key={index}
message={item}
onSelect={handleContentSelect}
/>
) : (
<ContentDropdownItem
key={index}
content={item}
onSelect={handleContentSelect}
/>
)
))}
{searchResults.length === 0 && searchTerm.length > 2 && (
<div className="p-4 text-center">No results found</div>
)}
</OverlayPanel>
</div>
);
};

View File

@ -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 };
};

View File

@ -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 };
};

17
src/hooks/useDebounce.js Normal file
View File

@ -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;
}

View File

@ -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 = {