diff --git a/src/components/content/dropdowns/ContentDropdownItem.js b/src/components/content/dropdowns/ContentDropdownItem.js index a4dd5d3..f9e3c79 100644 --- a/src/components/content/dropdowns/ContentDropdownItem.js +++ b/src/components/content/dropdowns/ContentDropdownItem.js @@ -7,11 +7,15 @@ import { Message } from 'primereact/message'; import GenericButton from '@/components/buttons/GenericButton'; import useWindowWidth from '@/hooks/useWindowWidth'; import { BookOpen } from 'lucide-react'; +import { highlightText, getTextWithMatchContext } from '@/utils/text'; const ContentDropdownItem = ({ content, onSelect }) => { const { returnImageProxy } = useImageProxy(); const windowWidth = useWindowWidth(); const isMobile = windowWidth <= 600; + + // Get match information if available + const matches = content?._matches || {}; return (
{

- {content?.title || content?.name} + {matches.title + ? highlightText( + content?.title || content?.name, + matches.title.term, + 'bg-yellow-500/30 text-white font-bold px-0.5 rounded' + ) + : (content?.title || content?.name)}

{content?.price > 0 ? ( @@ -59,7 +69,13 @@ const ContentDropdownItem = ({ content, onSelect }) => { {content?.summary && (

- {content.summary} + {matches.description + ? highlightText( + getTextWithMatchContext(content.summary, matches.description.term, 60), + matches.description.term, + 'bg-yellow-500/30 text-white font-medium px-0.5 rounded' + ) + : content.summary}

)}
diff --git a/src/components/content/dropdowns/MessageDropdownItem.js b/src/components/content/dropdowns/MessageDropdownItem.js index ede3407..49b38d3 100644 --- a/src/components/content/dropdowns/MessageDropdownItem.js +++ b/src/components/content/dropdowns/MessageDropdownItem.js @@ -8,6 +8,7 @@ import Image from 'next/image'; import { formatTimestampToHowLongAgo } from '@/utils/time'; import NostrIcon from '/public/images/nostr.png'; import { useImageProxy } from '@/hooks/useImageProxy'; +import { highlightText, getTextWithMatchContext } from '@/utils/text'; const MessageDropdownItem = ({ message, onSelect }) => { const { ndk } = useNDKContext(); @@ -20,6 +21,9 @@ const MessageDropdownItem = ({ message, onSelect }) => { // Stable reference to message to prevent infinite loops const messageRef = useMemo(() => message, [message?.id]); + + // Get match information + const matches = useMemo(() => message?._matches || {}, [message]); // Determine the platform once when component mounts or message changes const determinePlatform = useCallback(() => { @@ -85,6 +89,7 @@ const MessageDropdownItem = ({ message, onSelect }) => { avatarProxy: authorPicture ? returnImageProxy(authorPicture) : null, type: 'nostr', id: messageRef.id, + _matches: messageRef._matches }); } } else if (currentPlatform === 'discord') { @@ -104,6 +109,7 @@ const MessageDropdownItem = ({ message, onSelect }) => { channel: messageRef?.channel || 'discord', type: 'discord', id: messageRef.id, + _matches: messageRef._matches }); } } else if (currentPlatform === 'stackernews') { @@ -122,6 +128,7 @@ const MessageDropdownItem = ({ message, onSelect }) => { channel: '~devs', type: 'stackernews', id: messageRef.id, + _matches: messageRef._matches }); } } @@ -164,6 +171,16 @@ const MessageDropdownItem = ({ message, onSelect }) => { const messageDate = messageData.timestamp ? formatTimestampToHowLongAgo(messageData.timestamp) : ''; + + // Get the content with highlighting if there's a match + const contentMatches = messageData._matches?.content || messageData._matches?.title; + const displayContent = contentMatches + ? highlightText( + getTextWithMatchContext(messageData.content, contentMatches.term, 60), + contentMatches.term, + 'bg-yellow-500/30 text-white font-medium px-0.5 rounded' + ) + : messageData.content; return (
@@ -190,7 +207,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
{messageDate}

- {messageData.content} + {displayContent}

@@ -254,8 +271,19 @@ const MessageDropdownItem = ({ message, onSelect }) => { {messageData?.timestamp ? formatTimestampToHowLongAgo(messageData.timestamp) : ''}
+

- {messageData?.content} + {messageData?._matches?.content || messageData?._matches?.title + ? highlightText( + getTextWithMatchContext( + messageData?.content, + (messageData?._matches?.content || messageData?._matches?.title).term, + 80 + ), + (messageData?._matches?.content || messageData?._matches?.title).term, + 'bg-yellow-500/30 text-white font-medium px-0.5 rounded' + ) + : messageData?.content}

diff --git a/src/components/search/SearchBar.js b/src/components/search/SearchBar.js index 82e5f2e..e27bbab 100644 --- a/src/components/search/SearchBar.js +++ b/src/components/search/SearchBar.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { InputText } from 'primereact/inputtext'; import { Dropdown } from 'primereact/dropdown'; import { OverlayPanel } from 'primereact/overlaypanel'; @@ -11,20 +11,20 @@ import useWindowWidth from '@/hooks/useWindowWidth'; import { useNDKContext } from '@/context/NDKContext'; import { ProgressSpinner } from 'primereact/progressspinner'; +const SEARCH_OPTIONS = [ + { name: 'Content', code: 'content', icon: 'pi pi-video' }, + { name: 'Community', code: 'community', icon: 'pi pi-users' }, +]; + +const SEARCH_DELAY = 300; // ms +const MIN_SEARCH_LENGTH = 3; + const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { const { searchContent, searchResults: contentResults } = useContentSearch(); const { searchCommunity, searchResults: communityResults } = useCommunitySearch(); const router = useRouter(); const windowWidth = useWindowWidth(); - 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 [selectedSearchOption, setSelectedSearchOption] = useState(SEARCH_OPTIONS[0]); const [searchTerm, setSearchTerm] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); @@ -32,6 +32,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { const { ndk, reInitializeNDK } = useNDKContext(); const searchTimeout = useRef(null); + // Handle search option template rendering const selectedOptionTemplate = (option, props) => { if (isDesktopNav) { // For desktop nav bar, just show the icon @@ -53,6 +54,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { return ; }; + // Handle search input changes const handleSearch = e => { const term = e.target.value; setSearchTerm(term); @@ -63,13 +65,13 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } // Set loading state if term length is sufficient - if (term.length > 2) { + if (term.length >= MIN_SEARCH_LENGTH) { setIsSearching(true); } // Set a timeout to avoid searching on each keystroke searchTimeout.current = setTimeout(() => { - if (term.length > 2) { + if (term.length >= MIN_SEARCH_LENGTH) { if (selectedSearchOption.code === 'content') { searchContent(term); } else if (selectedSearchOption.code === 'community' && ndk) { @@ -78,15 +80,17 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } else { setIsSearching(false); } - }, 300); + }, SEARCH_DELAY); - if (!isMobileSearch && term.length > 2) { + // Show/hide overlay panel based on search term length + if (!isMobileSearch && term.length >= MIN_SEARCH_LENGTH) { op.current.show(e); } else if (!isMobileSearch) { op.current.hide(); } }; + // Update search results when option or results change useEffect(() => { if (selectedSearchOption.code === 'content') { setSearchResults(contentResults); @@ -95,7 +99,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } // Once we have results, set isSearching to false - if (searchTerm.length > 2) { + if (searchTerm.length >= MIN_SEARCH_LENGTH) { setIsSearching(false); } }, [selectedSearchOption, contentResults, communityResults, searchTerm]); @@ -109,6 +113,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { }; }, []); + // Handle WebSocket errors and reinitialize NDK if needed useEffect(() => { const handleError = event => { if (event.message && event.message.includes('wss://relay.devs.tools')) { @@ -124,6 +129,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { }; }, [reInitializeNDK]); + // Handle item selection from search results const handleContentSelect = content => { if (selectedSearchOption.code === 'content') { if (content?.type === 'course') { @@ -143,6 +149,12 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } } + // Reset search state + resetSearch(); + }; + + // Reset search state + const resetSearch = () => { setSearchTerm(''); searchContent(''); searchCommunity(''); @@ -166,6 +178,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } }; + // Render search results const renderSearchResults = () => { // Show loading spinner while searching if (isSearching) { @@ -177,10 +190,11 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } // Show no results message - if (searchResults.length === 0 && searchTerm.length > 2) { + if (searchResults.length === 0 && searchTerm.length >= MIN_SEARCH_LENGTH) { return
No results found
; } + // Render appropriate item component based on type return searchResults.map((item, index) => item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? ( @@ -195,7 +209,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { setSelectedSearchOption(e.value); // If there's a search term, run the search again with the new option - if (searchTerm.length > 2) { + if (searchTerm.length >= MIN_SEARCH_LENGTH) { setIsSearching(true); if (e.value.code === 'content') { searchContent(searchTerm); @@ -205,6 +219,13 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } }; + // Derived styles based on screen size + const searchWidth = useMemo(() => { + if (windowWidth > 845) return 'w-[300px]'; + if (isMobileSearch || windowWidth <= 600) return 'w-full'; + return 'w-[160px]'; + }, [windowWidth, isMobileSearch]); + return ( <>
@@ -244,7 +265,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { }} value={selectedSearchOption} onChange={handleSearchOptionChange} - options={searchOptions} + options={SEARCH_OPTIONS} optionLabel="name" dropdownIcon={} valueTemplate={selectedOptionTemplate} @@ -266,9 +287,9 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { 845 ? 'w-[300px]' : isMobileSearch || windowWidth <= 600 ? 'w-full' : 'w-[160px]'} - ${isMobileSearch ? 'bg-transparent border-none pl-12 text-lg' : ''} - `} + ${searchWidth} + ${isMobileSearch ? 'bg-transparent border-none pl-12 text-lg' : ''} + `} value={searchTerm} onChange={handleSearch} placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`} @@ -282,7 +303,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { {isMobileSearch && (
- {searchOptions.map(option => ( + {SEARCH_OPTIONS.map(option => (