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