limit to title/content only with improved match highlighting

This commit is contained in:
austinkelsay 2025-04-27 12:47:24 -05:00
parent 4c73fc3725
commit 40d0f42c46
No known key found for this signature in database
GPG Key ID: 5A763922E5BA08EE
6 changed files with 212 additions and 54 deletions

View File

@ -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>

View File

@ -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">

View File

@ -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' }}

View File

@ -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(

View File

@ -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);

View File

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