mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-04-19 19:01:19 +00:00
searchbar is functional with content and community
This commit is contained in:
parent
0619763786
commit
b27384c8a2
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
90
src/components/content/dropdowns/MessageDropdownItem.js
Normal file
90
src/components/content/dropdowns/MessageDropdownItem.js
Normal 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;
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
|
||||
|
@ -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'}
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
49
src/hooks/useCommunitySearch.js
Normal file
49
src/hooks/useCommunitySearch.js
Normal 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 };
|
||||
};
|
62
src/hooks/useContentSearch.js
Normal file
62
src/hooks/useContentSearch.js
Normal 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
17
src/hooks/useDebounce.js
Normal 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;
|
||||
}
|
@ -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 = {
|
||||
|
Loading…
x
Reference in New Issue
Block a user