diff --git a/src/components/content/dropdowns/ContentDropdownItem.js b/src/components/content/dropdowns/ContentDropdownItem.js index a4dd5d3..0de13f7 100644 --- a/src/components/content/dropdowns/ContentDropdownItem.js +++ b/src/components/content/dropdowns/ContentDropdownItem.js @@ -7,16 +7,49 @@ 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'; +import { generateNaddr } from '@/utils/nostr'; const ContentDropdownItem = ({ content, onSelect }) => { const { returnImageProxy } = useImageProxy(); const windowWidth = useWindowWidth(); const isMobile = windowWidth <= 600; + + // Get match information if available + const matches = content?._matches || {}; + + // Handle content selection with naddress + const handleSelect = () => { + // Create a copy of the content object with naddress information + const contentWithNaddr = { ...content }; + + // If content has pubkey, kind, and identifier (d tag), generate naddr + if (content.pubkey && content.kind && (content.d || content.identifier)) { + // Use the appropriate identifier (d tag value) + const identifier = content.d || content.identifier; + + // Generate naddress + contentWithNaddr.naddress = generateNaddr( + content.pubkey, + content.kind, + identifier + ); + + // Log success or failure + if (contentWithNaddr.naddress) { + console.log(`Generated naddress for ${content.type || 'content'}: ${contentWithNaddr.naddress}`); + } else { + console.warn('Failed to generate naddress:', { pubkey: content.pubkey, kind: content.kind, identifier }); + } + } + + onSelect(contentWithNaddr); + }; return (
onSelect(content)} + onClick={handleSelect} >
{

- {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 +98,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}

)}
@@ -91,7 +136,7 @@ const ContentDropdownItem = ({ content, onSelect }) => { iconPos="right" onClick={e => { e.stopPropagation(); - onSelect(content); + handleSelect(); }} className="items-center py-1 shadow-sm hover:shadow-md transition-shadow duration-200" /> diff --git a/src/components/content/dropdowns/MessageDropdownItem.js b/src/components/content/dropdowns/MessageDropdownItem.js index ede3407..b094928 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?.term || messageData?._matches?.title?.term, + 80 + ), + messageData?._matches?.content?.term || 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 55848b6..80d118d 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'; @@ -9,26 +9,30 @@ import { useCommunitySearch } from '@/hooks/useCommunitySearch'; import { useRouter } from 'next/router'; 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); const op = useRef(null); 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 @@ -50,33 +54,66 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { return ; }; + // Handle search input changes const handleSearch = e => { const term = e.target.value; setSearchTerm(term); - if (selectedSearchOption.code === 'content') { - searchContent(term); - setSearchResults(contentResults); - } else if (selectedSearchOption.code === 'community' && ndk) { - searchCommunity(term); - setSearchResults(communityResults); + // Clear any existing timeout to avoid unnecessary API calls + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); } - if (!isMobileSearch && term.length > 2) { + // Set loading state if term length is sufficient + if (term.length >= MIN_SEARCH_LENGTH) { + setIsSearching(true); + } + + // Set a timeout to avoid searching on each keystroke + searchTimeout.current = setTimeout(() => { + if (term.length >= MIN_SEARCH_LENGTH) { + if (selectedSearchOption.code === 'content') { + searchContent(term); + } else if (selectedSearchOption.code === 'community' && ndk) { + searchCommunity(term); + } + } else { + setIsSearching(false); + } + }, SEARCH_DELAY); + + // 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); } else if (selectedSearchOption.code === 'community') { setSearchResults(communityResults); } - }, [selectedSearchOption, contentResults, communityResults]); + + // Once we have results, set isSearching to false + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + setIsSearching(false); + } + }, [selectedSearchOption, contentResults, communityResults, searchTerm]); + // Cleanup the timeout on component unmount + useEffect(() => { + return () => { + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } + }; + }, []); + + // Handle WebSocket errors and reinitialize NDK if needed useEffect(() => { const handleError = event => { if (event.message && event.message.includes('wss://relay.devs.tools')) { @@ -92,11 +129,22 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { }; }, [reInitializeNDK]); + // Handle item selection from search results const handleContentSelect = content => { if (selectedSearchOption.code === 'content') { if (content?.type === 'course') { - router.push(`/course/${content?.d || content?.id}`); + if (content?.naddress) { + // Use naddress for course if available + router.push(`/course/${content.naddress}`); + } else { + // Fallback to d or id + router.push(`/course/${content?.d || content?.id}`); + } + } else if (content?.naddress) { + // Use naddress for other content if available + router.push(`/details/${content.naddress}`); } else { + // Fallback to ID if naddress is not available router.push(`/details/${content.id}`); } } else if (selectedSearchOption.code === 'community') { @@ -111,10 +159,17 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } } + // Reset search state + resetSearch(); + }; + + // Reset search state + const resetSearch = () => { setSearchTerm(''); searchContent(''); searchCommunity(''); setSearchResults([]); + setIsSearching(false); if (op.current) { op.current.hide(); @@ -133,11 +188,23 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { } }; + // Render search results const renderSearchResults = () => { - if (searchResults.length === 0 && searchTerm.length > 2) { + // Show loading spinner while searching + if (isSearching) { + return ( +
+ +
+ ); + } + + // Show no results message + 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' ? ( @@ -147,6 +214,28 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { ); }; + // When search option changes, trigger search with current term + const handleSearchOptionChange = e => { + setSelectedSearchOption(e.value); + + // If there's a search term, run the search again with the new option + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + setIsSearching(true); + if (e.value.code === 'content') { + searchContent(searchTerm); + } else if (e.value.code === 'community' && ndk) { + searchCommunity(searchTerm); + } + } + }; + + // 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 ( <>
@@ -185,8 +274,8 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { }, }} value={selectedSearchOption} - onChange={e => setSelectedSearchOption(e.value)} - options={searchOptions} + onChange={handleSearchOptionChange} + options={SEARCH_OPTIONS} optionLabel="name" dropdownIcon={} valueTemplate={selectedOptionTemplate} @@ -208,9 +297,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()}`} @@ -224,10 +313,10 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { {isMobileSearch && (
- {searchOptions.map(option => ( + {SEARCH_OPTIONS.map(option => (