From 40d0f42c46e2c98e13c619acab39146ec6b55db7 Mon Sep 17 00:00:00 2001 From: austinkelsay <austinkelsay@protonmail.com> Date: Sun, 27 Apr 2025 12:47:24 -0500 Subject: [PATCH] limit to title/content only with improved match highlighting --- .../content/dropdowns/ContentDropdownItem.js | 20 +++++- .../content/dropdowns/MessageDropdownItem.js | 32 ++++++++- src/components/search/SearchBar.js | 69 +++++++++++------- src/hooks/useCommunitySearch.js | 39 ++++++++-- src/hooks/useContentSearch.js | 35 +++++---- src/utils/text.js | 71 ++++++++++++++++--- 6 files changed, 212 insertions(+), 54 deletions(-) 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 ( <div @@ -39,7 +43,13 @@ const ContentDropdownItem = ({ content, onSelect }) => { <div> <div className="flex justify-between items-start gap-4 mb-2"> <h3 className="text-xl font-bold text-[#f8f8ff] group-hover:text-white transition-colors duration-200"> - {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)} </h3> {content?.price > 0 ? ( @@ -59,7 +69,13 @@ const ContentDropdownItem = ({ content, onSelect }) => { {content?.summary && ( <p className="text-neutral-50/80 line-clamp-2 mb-3 text-sm leading-relaxed group-hover:text-neutral-50/90 transition-colors duration-200"> - {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} </p> )} </div> 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 ( <div className="flex flex-col"> @@ -190,7 +207,7 @@ const MessageDropdownItem = ({ message, onSelect }) => { <div className="text-xs text-gray-400">{messageDate}</div> </div> <p className="text-neutral-50/90 whitespace-pre-wrap mb-3 line-clamp-3"> - {messageData.content} + {displayContent} </p> <div className="flex flex-wrap gap-2"> @@ -254,8 +271,19 @@ const MessageDropdownItem = ({ message, onSelect }) => { {messageData?.timestamp ? formatTimestampToHowLongAgo(messageData.timestamp) : ''} </div> </div> + <p className="text-neutral-50/80 whitespace-pre-wrap mb-3 text-sm leading-relaxed group-hover:text-neutral-50/90 transition-colors duration-200"> - {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} </p> <div className="flex flex-wrap gap-2"> 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 <i className={option.icon + ' text-transparent text-xs'} />; }; + // 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 <div className="p-4 text-center text-gray-400">No results found</div>; } + // Render appropriate item component based on type return searchResults.map((item, index) => item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? ( <MessageDropdownItem key={index} message={item} onSelect={handleContentSelect} /> @@ -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 ( <> <div className={`${isDesktopNav ? 'w-full max-w-md' : 'w-full'}`}> @@ -244,7 +265,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { }} value={selectedSearchOption} onChange={handleSearchOptionChange} - options={searchOptions} + options={SEARCH_OPTIONS} optionLabel="name" dropdownIcon={<i className="pi pi-chevron-down text-gray-400 ml-1" />} valueTemplate={selectedOptionTemplate} @@ -266,9 +287,9 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { <i className="pi pi-search text-gray-400 absolute left-4 z-10" /> <InputText className={` - ${windowWidth > 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 && ( <div className="flex items-center gap-2 mb-3"> - {searchOptions.map(option => ( + {SEARCH_OPTIONS.map(option => ( <button key={option.code} onClick={() => handleSearchOptionChange({ value: option })} @@ -312,7 +333,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { }} value={selectedSearchOption} onChange={handleSearchOptionChange} - options={searchOptions} + options={SEARCH_OPTIONS} optionLabel="name" placeholder="Search" dropdownIcon={ @@ -338,7 +359,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => { )} {/* Mobile Search Results */} - {isMobileSearch && searchTerm.length > 2 && ( + {isMobileSearch && searchTerm.length >= MIN_SEARCH_LENGTH && ( <div className="fixed inset-x-0 bottom-0 top-[165px] bg-gray-900 overflow-y-auto" style={{ touchAction: 'pan-y' }} diff --git a/src/hooks/useCommunitySearch.js b/src/hooks/useCommunitySearch.js index c8d7221..6df3340 100644 --- a/src/hooks/useCommunitySearch.js +++ b/src/hooks/useCommunitySearch.js @@ -26,29 +26,56 @@ export const useCommunitySearch = () => { const lowercaseTerm = term.toLowerCase(); - // Discord search + // Discord search - match only on message content const filteredDiscord = (discordData || []) .filter(message => { if (!message.content) return false; return message.content.toLowerCase().includes(lowercaseTerm); }) - .map(message => ({ ...message, type: 'discord' })); + .map(message => ({ + ...message, + type: 'discord', + _matches: { + content: { + text: message.content, + term: lowercaseTerm + } + } + })); - // Nostr search + // Nostr search - match only on message content const filteredNostr = (nostrData || []) .filter(message => { if (!message.content) return false; return message.content.toLowerCase().includes(lowercaseTerm); }) - .map(message => ({ ...message, type: 'nostr' })); + .map(message => ({ + ...message, + type: 'nostr', + _matches: { + content: { + text: message.content, + term: lowercaseTerm + } + } + })); - // StackerNews search + // StackerNews search - match only on title const filteredStackerNews = (stackerNewsData || []) .filter(item => { if (!item.title) return false; return item.title.toLowerCase().includes(lowercaseTerm); }) - .map(item => ({ ...item, type: 'stackernews' })); + .map(item => ({ + ...item, + type: 'stackernews', + _matches: { + title: { + text: item.title, + term: lowercaseTerm + } + } + })); // Combine and sort the results const combinedResults = [...filteredDiscord, ...filteredNostr, ...filteredStackerNews].sort( diff --git a/src/hooks/useContentSearch.js b/src/hooks/useContentSearch.js index b3da163..2a0a448 100644 --- a/src/hooks/useContentSearch.js +++ b/src/hooks/useContentSearch.js @@ -46,21 +46,32 @@ export const useContentSearch = () => { if (term.length > 2) { const searchTerm = term.toLowerCase(); const filtered = allContent.filter(content => { - // Search in title/name + // Prepare fields to search in const searchableTitle = (content?.title || content?.name || '').toLowerCase(); - if (searchableTitle.includes(searchTerm)) return true; + const searchableDescription = (content?.summary || content?.description || '').toLowerCase(); - // Search in summary/description - const searchableDescription = ( - content?.summary || - content?.description || - '' - ).toLowerCase(); - if (searchableDescription.includes(searchTerm)) return true; + // Find matches in title + const titleMatch = searchableTitle.includes(searchTerm); - // Search in topics/tags - const topics = content?.topics || []; - return topics.some(topic => topic.toLowerCase().includes(searchTerm)); + // Find matches in description + const descriptionMatch = searchableDescription.includes(searchTerm); + + // Store match information (only for title and description) + if (titleMatch || descriptionMatch) { + content._matches = { + title: titleMatch ? { + text: content?.title || content?.name || '', + term: searchTerm + } : null, + description: descriptionMatch ? { + text: content?.summary || content?.description || '', + term: searchTerm + } : null + }; + return true; + } + + return false; }); setSearchResults(filtered); diff --git a/src/utils/text.js b/src/utils/text.js index dfd91ca..748c1e0 100644 --- a/src/utils/text.js +++ b/src/utils/text.js @@ -1,13 +1,68 @@ -export const highlightText = (text, query) => { - if (!query) return text; - const parts = text.split(new RegExp(`(${query})`, 'gi')); - return parts.map((part, index) => - part.toLowerCase() === query.toLowerCase() ? ( - <span key={index} className="text-yellow-300"> +import React from 'react'; + +/** + * Highlights occurrences of a search term within a text string + * + * @param {string} text - The original text to process + * @param {string} term - The search term to highlight + * @param {string} className - CSS class name to apply to highlighted text + * @returns {JSX.Element[]} - Array of text and highlighted spans + */ +export const highlightText = (text, term, className = 'bg-yellow-300/30 text-white') => { + if (!text || !term || term.length < 2) { + return text; + } + + const parts = String(text).split(new RegExp(`(${term})`, 'gi')); + + return parts.map((part, index) => { + const isMatch = part.toLowerCase() === term.toLowerCase(); + + return isMatch ? ( + <span key={index} className={className}> {part} </span> ) : ( part - ) - ); + ); + }); +}; + +/** + * Truncates text around the first match of a search term + * + * @param {string} text - The original text to process + * @param {string} term - The search term to find + * @param {number} contextLength - Number of characters to include before and after match + * @returns {string} - Truncated text with match in the middle + */ +export const getTextWithMatchContext = (text, term, contextLength = 40) => { + if (!text || !term || term.length < 2) { + return text; + } + + const lowerText = text.toLowerCase(); + const lowerTerm = term.toLowerCase(); + const matchIndex = lowerText.indexOf(lowerTerm); + + if (matchIndex === -1) { + return text.length > contextLength * 2 + ? text.substring(0, contextLength * 2) + '...' + : text; + } + + const start = Math.max(0, matchIndex - contextLength); + const end = Math.min(text.length, matchIndex + term.length + contextLength); + + let result = text.substring(start, end); + + if (start > 0) { + result = '...' + result; + } + + if (end < text.length) { + result = result + '...'; + } + + return result; };