- {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 => (