mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-23 16:05:24 +00:00
Merge pull request #69 from AustinKelsay/bugfix-search-keyword-matching
Bugfix search keyword matching
This commit is contained in:
commit
e7d8677806
@ -7,16 +7,49 @@ import { Message } from 'primereact/message';
|
|||||||
import GenericButton from '@/components/buttons/GenericButton';
|
import GenericButton from '@/components/buttons/GenericButton';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import { BookOpen } from 'lucide-react';
|
import { BookOpen } from 'lucide-react';
|
||||||
|
import { highlightText, getTextWithMatchContext } from '@/utils/text';
|
||||||
|
import { generateNaddr } from '@/utils/nostr';
|
||||||
|
|
||||||
const ContentDropdownItem = ({ content, onSelect }) => {
|
const ContentDropdownItem = ({ content, onSelect }) => {
|
||||||
const { returnImageProxy } = useImageProxy();
|
const { returnImageProxy } = useImageProxy();
|
||||||
const windowWidth = useWindowWidth();
|
const windowWidth = useWindowWidth();
|
||||||
const isMobile = windowWidth <= 600;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group px-6 py-5 border-b border-gray-700/50 cursor-pointer hover:bg-gray-800/30 transition-colors duration-200"
|
className="group px-6 py-5 border-b border-gray-700/50 cursor-pointer hover:bg-gray-800/30 transition-colors duration-200"
|
||||||
onClick={() => onSelect(content)}
|
onClick={handleSelect}
|
||||||
>
|
>
|
||||||
<div className={`flex ${isMobile ? 'flex-col' : 'flex-row'} gap-5`}>
|
<div className={`flex ${isMobile ? 'flex-col' : 'flex-row'} gap-5`}>
|
||||||
<div
|
<div
|
||||||
@ -39,7 +72,13 @@ const ContentDropdownItem = ({ content, onSelect }) => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-start gap-4 mb-2">
|
<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">
|
<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>
|
</h3>
|
||||||
|
|
||||||
{content?.price > 0 ? (
|
{content?.price > 0 ? (
|
||||||
@ -59,7 +98,13 @@ const ContentDropdownItem = ({ content, onSelect }) => {
|
|||||||
|
|
||||||
{content?.summary && (
|
{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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -91,7 +136,7 @@ const ContentDropdownItem = ({ content, onSelect }) => {
|
|||||||
iconPos="right"
|
iconPos="right"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect(content);
|
handleSelect();
|
||||||
}}
|
}}
|
||||||
className="items-center py-1 shadow-sm hover:shadow-md transition-shadow duration-200"
|
className="items-center py-1 shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||||
/>
|
/>
|
||||||
|
@ -8,6 +8,7 @@ import Image from 'next/image';
|
|||||||
import { formatTimestampToHowLongAgo } from '@/utils/time';
|
import { formatTimestampToHowLongAgo } from '@/utils/time';
|
||||||
import NostrIcon from '/public/images/nostr.png';
|
import NostrIcon from '/public/images/nostr.png';
|
||||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
|
import { highlightText, getTextWithMatchContext } from '@/utils/text';
|
||||||
|
|
||||||
const MessageDropdownItem = ({ message, onSelect }) => {
|
const MessageDropdownItem = ({ message, onSelect }) => {
|
||||||
const { ndk } = useNDKContext();
|
const { ndk } = useNDKContext();
|
||||||
@ -20,6 +21,9 @@ const MessageDropdownItem = ({ message, onSelect }) => {
|
|||||||
|
|
||||||
// Stable reference to message to prevent infinite loops
|
// Stable reference to message to prevent infinite loops
|
||||||
const messageRef = useMemo(() => message, [message?.id]);
|
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
|
// Determine the platform once when component mounts or message changes
|
||||||
const determinePlatform = useCallback(() => {
|
const determinePlatform = useCallback(() => {
|
||||||
@ -85,6 +89,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
|
|||||||
avatarProxy: authorPicture ? returnImageProxy(authorPicture) : null,
|
avatarProxy: authorPicture ? returnImageProxy(authorPicture) : null,
|
||||||
type: 'nostr',
|
type: 'nostr',
|
||||||
id: messageRef.id,
|
id: messageRef.id,
|
||||||
|
_matches: messageRef._matches
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (currentPlatform === 'discord') {
|
} else if (currentPlatform === 'discord') {
|
||||||
@ -104,6 +109,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
|
|||||||
channel: messageRef?.channel || 'discord',
|
channel: messageRef?.channel || 'discord',
|
||||||
type: 'discord',
|
type: 'discord',
|
||||||
id: messageRef.id,
|
id: messageRef.id,
|
||||||
|
_matches: messageRef._matches
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (currentPlatform === 'stackernews') {
|
} else if (currentPlatform === 'stackernews') {
|
||||||
@ -122,6 +128,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
|
|||||||
channel: '~devs',
|
channel: '~devs',
|
||||||
type: 'stackernews',
|
type: 'stackernews',
|
||||||
id: messageRef.id,
|
id: messageRef.id,
|
||||||
|
_matches: messageRef._matches
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,6 +171,16 @@ const MessageDropdownItem = ({ message, onSelect }) => {
|
|||||||
const messageDate = messageData.timestamp
|
const messageDate = messageData.timestamp
|
||||||
? formatTimestampToHowLongAgo(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 (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@ -190,7 +207,7 @@ const MessageDropdownItem = ({ message, onSelect }) => {
|
|||||||
<div className="text-xs text-gray-400">{messageDate}</div>
|
<div className="text-xs text-gray-400">{messageDate}</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-neutral-50/90 whitespace-pre-wrap mb-3 line-clamp-3">
|
<p className="text-neutral-50/90 whitespace-pre-wrap mb-3 line-clamp-3">
|
||||||
{messageData.content}
|
{displayContent}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@ -254,8 +271,19 @@ const MessageDropdownItem = ({ message, onSelect }) => {
|
|||||||
{messageData?.timestamp ? formatTimestampToHowLongAgo(messageData.timestamp) : ''}
|
{messageData?.timestamp ? formatTimestampToHowLongAgo(messageData.timestamp) : ''}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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?.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}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
@ -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 { InputText } from 'primereact/inputtext';
|
||||||
import { Dropdown } from 'primereact/dropdown';
|
import { Dropdown } from 'primereact/dropdown';
|
||||||
import { OverlayPanel } from 'primereact/overlaypanel';
|
import { OverlayPanel } from 'primereact/overlaypanel';
|
||||||
@ -9,26 +9,30 @@ import { useCommunitySearch } from '@/hooks/useCommunitySearch';
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import { useNDKContext } from '@/context/NDKContext';
|
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 SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
||||||
const { searchContent, searchResults: contentResults } = useContentSearch();
|
const { searchContent, searchResults: contentResults } = useContentSearch();
|
||||||
const { searchCommunity, searchResults: communityResults } = useCommunitySearch();
|
const { searchCommunity, searchResults: communityResults } = useCommunitySearch();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const windowWidth = useWindowWidth();
|
const windowWidth = useWindowWidth();
|
||||||
const [selectedSearchOption, setSelectedSearchOption] = useState({
|
const [selectedSearchOption, setSelectedSearchOption] = useState(SEARCH_OPTIONS[0]);
|
||||||
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 [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState([]);
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const op = useRef(null);
|
const op = useRef(null);
|
||||||
const { ndk, reInitializeNDK } = useNDKContext();
|
const { ndk, reInitializeNDK } = useNDKContext();
|
||||||
|
const searchTimeout = useRef(null);
|
||||||
|
|
||||||
|
// Handle search option template rendering
|
||||||
const selectedOptionTemplate = (option, props) => {
|
const selectedOptionTemplate = (option, props) => {
|
||||||
if (isDesktopNav) {
|
if (isDesktopNav) {
|
||||||
// For desktop nav bar, just show the icon
|
// For desktop nav bar, just show the icon
|
||||||
@ -50,33 +54,66 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
return <i className={option.icon + ' text-transparent text-xs'} />;
|
return <i className={option.icon + ' text-transparent text-xs'} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle search input changes
|
||||||
const handleSearch = e => {
|
const handleSearch = e => {
|
||||||
const term = e.target.value;
|
const term = e.target.value;
|
||||||
setSearchTerm(term);
|
setSearchTerm(term);
|
||||||
|
|
||||||
if (selectedSearchOption.code === 'content') {
|
// Clear any existing timeout to avoid unnecessary API calls
|
||||||
searchContent(term);
|
if (searchTimeout.current) {
|
||||||
setSearchResults(contentResults);
|
clearTimeout(searchTimeout.current);
|
||||||
} else if (selectedSearchOption.code === 'community' && ndk) {
|
|
||||||
searchCommunity(term);
|
|
||||||
setSearchResults(communityResults);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
op.current.show(e);
|
||||||
} else if (!isMobileSearch) {
|
} else if (!isMobileSearch) {
|
||||||
op.current.hide();
|
op.current.hide();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Update search results when option or results change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSearchOption.code === 'content') {
|
if (selectedSearchOption.code === 'content') {
|
||||||
setSearchResults(contentResults);
|
setSearchResults(contentResults);
|
||||||
} else if (selectedSearchOption.code === 'community') {
|
} else if (selectedSearchOption.code === 'community') {
|
||||||
setSearchResults(communityResults);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleError = event => {
|
const handleError = event => {
|
||||||
if (event.message && event.message.includes('wss://relay.devs.tools')) {
|
if (event.message && event.message.includes('wss://relay.devs.tools')) {
|
||||||
@ -92,11 +129,22 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
};
|
};
|
||||||
}, [reInitializeNDK]);
|
}, [reInitializeNDK]);
|
||||||
|
|
||||||
|
// Handle item selection from search results
|
||||||
const handleContentSelect = content => {
|
const handleContentSelect = content => {
|
||||||
if (selectedSearchOption.code === 'content') {
|
if (selectedSearchOption.code === 'content') {
|
||||||
if (content?.type === 'course') {
|
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 {
|
} else {
|
||||||
|
// Fallback to ID if naddress is not available
|
||||||
router.push(`/details/${content.id}`);
|
router.push(`/details/${content.id}`);
|
||||||
}
|
}
|
||||||
} else if (selectedSearchOption.code === 'community') {
|
} else if (selectedSearchOption.code === 'community') {
|
||||||
@ -111,10 +159,17 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset search state
|
||||||
|
resetSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset search state
|
||||||
|
const resetSearch = () => {
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
searchContent('');
|
searchContent('');
|
||||||
searchCommunity('');
|
searchCommunity('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setIsSearching(false);
|
||||||
|
|
||||||
if (op.current) {
|
if (op.current) {
|
||||||
op.current.hide();
|
op.current.hide();
|
||||||
@ -133,11 +188,23 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Render search results
|
||||||
const renderSearchResults = () => {
|
const renderSearchResults = () => {
|
||||||
if (searchResults.length === 0 && searchTerm.length > 2) {
|
// Show loading spinner while searching
|
||||||
|
if (isSearching) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-6">
|
||||||
|
<ProgressSpinner style={{ width: '50px', height: '50px' }} strokeWidth="4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show no results message
|
||||||
|
if (searchResults.length === 0 && searchTerm.length >= MIN_SEARCH_LENGTH) {
|
||||||
return <div className="p-4 text-center text-gray-400">No results found</div>;
|
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) =>
|
return searchResults.map((item, index) =>
|
||||||
item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
|
item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
|
||||||
<MessageDropdownItem key={index} message={item} onSelect={handleContentSelect} />
|
<MessageDropdownItem key={index} message={item} onSelect={handleContentSelect} />
|
||||||
@ -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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={`${isDesktopNav ? 'w-full max-w-md' : 'w-full'}`}>
|
<div className={`${isDesktopNav ? 'w-full max-w-md' : 'w-full'}`}>
|
||||||
@ -185,8 +274,8 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
value={selectedSearchOption}
|
value={selectedSearchOption}
|
||||||
onChange={e => setSelectedSearchOption(e.value)}
|
onChange={handleSearchOptionChange}
|
||||||
options={searchOptions}
|
options={SEARCH_OPTIONS}
|
||||||
optionLabel="name"
|
optionLabel="name"
|
||||||
dropdownIcon={<i className="pi pi-chevron-down text-gray-400 ml-1" />}
|
dropdownIcon={<i className="pi pi-chevron-down text-gray-400 ml-1" />}
|
||||||
valueTemplate={selectedOptionTemplate}
|
valueTemplate={selectedOptionTemplate}
|
||||||
@ -208,9 +297,9 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
<i className="pi pi-search text-gray-400 absolute left-4 z-10" />
|
<i className="pi pi-search text-gray-400 absolute left-4 z-10" />
|
||||||
<InputText
|
<InputText
|
||||||
className={`
|
className={`
|
||||||
${windowWidth > 845 ? 'w-[300px]' : isMobileSearch || windowWidth <= 600 ? 'w-full' : 'w-[160px]'}
|
${searchWidth}
|
||||||
${isMobileSearch ? 'bg-transparent border-none pl-12 text-lg' : ''}
|
${isMobileSearch ? 'bg-transparent border-none pl-12 text-lg' : ''}
|
||||||
`}
|
`}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={handleSearch}
|
onChange={handleSearch}
|
||||||
placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
|
placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
|
||||||
@ -224,10 +313,10 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
|
|
||||||
{isMobileSearch && (
|
{isMobileSearch && (
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
{searchOptions.map(option => (
|
{SEARCH_OPTIONS.map(option => (
|
||||||
<button
|
<button
|
||||||
key={option.code}
|
key={option.code}
|
||||||
onClick={() => setSelectedSearchOption(option)}
|
onClick={() => handleSearchOptionChange({ value: option })}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-full ${
|
className={`flex items-center gap-2 px-4 py-2 rounded-full ${
|
||||||
selectedSearchOption.code === option.code
|
selectedSearchOption.code === option.code
|
||||||
? 'bg-gray-700 text-white'
|
? 'bg-gray-700 text-white'
|
||||||
@ -253,8 +342,8 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
value={selectedSearchOption}
|
value={selectedSearchOption}
|
||||||
onChange={e => setSelectedSearchOption(e.value)}
|
onChange={handleSearchOptionChange}
|
||||||
options={searchOptions}
|
options={SEARCH_OPTIONS}
|
||||||
optionLabel="name"
|
optionLabel="name"
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
dropdownIcon={
|
dropdownIcon={
|
||||||
@ -280,7 +369,7 @@ const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Search Results */}
|
{/* Mobile Search Results */}
|
||||||
{isMobileSearch && searchTerm.length > 2 && (
|
{isMobileSearch && searchTerm.length >= MIN_SEARCH_LENGTH && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-x-0 bottom-0 top-[165px] bg-gray-900 overflow-y-auto"
|
className="fixed inset-x-0 bottom-0 top-[165px] bg-gray-900 overflow-y-auto"
|
||||||
style={{ touchAction: 'pan-y' }}
|
style={{ touchAction: 'pan-y' }}
|
||||||
|
@ -26,25 +26,73 @@ export const useCommunitySearch = () => {
|
|||||||
|
|
||||||
const lowercaseTerm = term.toLowerCase();
|
const lowercaseTerm = term.toLowerCase();
|
||||||
|
|
||||||
|
// Discord search - match only on message content
|
||||||
const filteredDiscord = (discordData || [])
|
const filteredDiscord = (discordData || [])
|
||||||
.filter(message => message.content.toLowerCase().includes(lowercaseTerm))
|
.filter(message => {
|
||||||
.map(message => ({ ...message, type: 'discord' }));
|
if (!message.content) return false;
|
||||||
|
return message.content.toLowerCase().includes(lowercaseTerm);
|
||||||
|
})
|
||||||
|
.map(message => ({
|
||||||
|
...message,
|
||||||
|
type: 'discord',
|
||||||
|
_matches: {
|
||||||
|
content: {
|
||||||
|
text: message.content,
|
||||||
|
term: lowercaseTerm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Nostr search - match only on message content
|
||||||
const filteredNostr = (nostrData || [])
|
const filteredNostr = (nostrData || [])
|
||||||
.filter(message => message.content.toLowerCase().includes(lowercaseTerm))
|
.filter(message => {
|
||||||
.map(message => ({ ...message, type: 'nostr' }));
|
if (!message.content) return false;
|
||||||
|
return message.content.toLowerCase().includes(lowercaseTerm);
|
||||||
|
})
|
||||||
|
.map(message => ({
|
||||||
|
...message,
|
||||||
|
type: 'nostr',
|
||||||
|
_matches: {
|
||||||
|
content: {
|
||||||
|
text: message.content,
|
||||||
|
term: lowercaseTerm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// StackerNews search - match only on title
|
||||||
const filteredStackerNews = (stackerNewsData || [])
|
const filteredStackerNews = (stackerNewsData || [])
|
||||||
.filter(item => item.title.toLowerCase().includes(lowercaseTerm))
|
.filter(item => {
|
||||||
.map(item => ({ ...item, type: 'stackernews' }));
|
if (!item.title) return false;
|
||||||
|
return item.title.toLowerCase().includes(lowercaseTerm);
|
||||||
|
})
|
||||||
|
.map(item => ({
|
||||||
|
...item,
|
||||||
|
type: 'stackernews',
|
||||||
|
_matches: {
|
||||||
|
title: {
|
||||||
|
text: item.title,
|
||||||
|
term: lowercaseTerm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Combine and sort the results
|
||||||
const combinedResults = [...filteredDiscord, ...filteredNostr, ...filteredStackerNews].sort(
|
const combinedResults = [...filteredDiscord, ...filteredNostr, ...filteredStackerNews].sort(
|
||||||
(a, b) => {
|
(a, b) => {
|
||||||
const dateA =
|
// Get timestamps in a consistent format (milliseconds)
|
||||||
a.type === 'nostr' ? a.created_at * 1000 : new Date(a.timestamp || a.createdAt);
|
const getTimestamp = item => {
|
||||||
const dateB =
|
if (item.type === 'nostr') {
|
||||||
b.type === 'nostr' ? b.created_at * 1000 : new Date(b.timestamp || b.createdAt);
|
return item.created_at * 1000;
|
||||||
return dateB - dateA;
|
} else if (item.type === 'discord') {
|
||||||
|
return new Date(item.timestamp).getTime();
|
||||||
|
} else if (item.type === 'stackernews') {
|
||||||
|
return new Date(item.createdAt).getTime();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return getTimestamp(b) - getTimestamp(a);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ export const useContentSearch = () => {
|
|||||||
};
|
};
|
||||||
const events = await ndk.fetchEvents(filter);
|
const events = await ndk.fetchEvents(filter);
|
||||||
|
|
||||||
const parsedEvents = new Set();
|
const parsedEvents = [];
|
||||||
events.forEach(event => {
|
events.forEach(event => {
|
||||||
let parsed;
|
let parsed;
|
||||||
if (event.kind === 30004) {
|
if (event.kind === 30004) {
|
||||||
@ -28,7 +28,7 @@ export const useContentSearch = () => {
|
|||||||
} else {
|
} else {
|
||||||
parsed = parseEvent(event);
|
parsed = parseEvent(event);
|
||||||
}
|
}
|
||||||
parsedEvents.add(parsed);
|
parsedEvents.push(parsed);
|
||||||
});
|
});
|
||||||
setAllContent(parsedEvents);
|
setAllContent(parsedEvents);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -44,17 +44,36 @@ export const useContentSearch = () => {
|
|||||||
|
|
||||||
const searchContent = term => {
|
const searchContent = term => {
|
||||||
if (term.length > 2) {
|
if (term.length > 2) {
|
||||||
const filtered = Array.from(allContent).filter(content => {
|
const searchTerm = term.toLowerCase();
|
||||||
|
const filtered = allContent.filter(content => {
|
||||||
|
// Prepare fields to search in
|
||||||
const searchableTitle = (content?.title || content?.name || '').toLowerCase();
|
const searchableTitle = (content?.title || content?.name || '').toLowerCase();
|
||||||
const searchableDescription = (
|
const searchableDescription = (content?.summary || content?.description || '').toLowerCase();
|
||||||
content?.summary ||
|
|
||||||
content?.description ||
|
// Find matches in title
|
||||||
''
|
const titleMatch = searchableTitle.includes(searchTerm);
|
||||||
).toLowerCase();
|
|
||||||
const searchTerm = term.toLowerCase();
|
// Find matches in description
|
||||||
|
const descriptionMatch = searchableDescription.includes(searchTerm);
|
||||||
return searchableTitle.includes(searchTerm) || 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);
|
setSearchResults(filtered);
|
||||||
} else {
|
} else {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
@ -221,6 +221,44 @@ export const hexToNpub = hex => {
|
|||||||
return nip19.npubEncode(hex);
|
return nip19.npubEncode(hex);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a Nostr address (naddr) from event details
|
||||||
|
*
|
||||||
|
* @param {string} pubkey - The public key of the content creator
|
||||||
|
* @param {number} kind - The event kind
|
||||||
|
* @param {string} identifier - The 'd' tag value
|
||||||
|
* @param {Array} relays - Optional array of relay URLs
|
||||||
|
* @returns {string} - The naddr string
|
||||||
|
*/
|
||||||
|
export const generateNaddr = (pubkey, kind, identifier, relays = []) => {
|
||||||
|
try {
|
||||||
|
// Convert npub to hex if needed
|
||||||
|
let hexPubkey = pubkey;
|
||||||
|
if (pubkey.startsWith('npub')) {
|
||||||
|
try {
|
||||||
|
const { data } = nip19.decode(pubkey);
|
||||||
|
hexPubkey = data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error decoding npub:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the address data
|
||||||
|
const addressData = {
|
||||||
|
pubkey: hexPubkey,
|
||||||
|
kind: parseInt(kind),
|
||||||
|
identifier,
|
||||||
|
relays
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate and return the naddr
|
||||||
|
return nip19.naddrEncode(addressData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating naddr:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export function validateEvent(event) {
|
export function validateEvent(event) {
|
||||||
if (typeof event.kind !== 'number') return 'Invalid kind';
|
if (typeof event.kind !== 'number') return 'Invalid kind';
|
||||||
if (typeof event.content !== 'string') return 'Invalid content';
|
if (typeof event.content !== 'string') return 'Invalid content';
|
||||||
|
@ -1,13 +1,68 @@
|
|||||||
export const highlightText = (text, query) => {
|
import React from 'react';
|
||||||
if (!query) return text;
|
|
||||||
const parts = text.split(new RegExp(`(${query})`, 'gi'));
|
/**
|
||||||
return parts.map((part, index) =>
|
* Highlights occurrences of a search term within a text string
|
||||||
part.toLowerCase() === query.toLowerCase() ? (
|
*
|
||||||
<span key={index} className="text-yellow-300">
|
* @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}
|
{part}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
part
|
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;
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user