mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-05 00:32:03 +00:00
limit to title/content only with improved match highlighting
This commit is contained in:
parent
4c73fc3725
commit
40d0f42c46
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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' }}
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user