mirror of
				https://github.com/walkxcode/dashboard-icons.git
				synced 2025-10-31 08:47:57 +01:00 
			
		
		
		
	feat(web): Refactor icon filtering and sorting (#1288)
* feat(web): Refactor icon filtering and sorting logic using a new utility function * feat(command-menu): Improve display and performance of cmd+k menu * fix(utils): Adjust scoring logic in fuzzySearch and filter thresholds
This commit is contained in:
		| @@ -2,19 +2,15 @@ | ||||
|  | ||||
| import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" | ||||
| import { useMediaQuery } from "@/hooks/use-media-query" | ||||
| import { formatIconName, fuzzySearch } from "@/lib/utils" | ||||
| import { formatIconName, fuzzySearch, filterAndSortIcons } from "@/lib/utils" | ||||
| import { useRouter } from "next/navigation" | ||||
| import { useCallback, useEffect, useState } from "react" | ||||
| import { useCallback, useEffect, useState, useMemo } from "react" | ||||
| import type { IconWithName } from "@/types/icons" | ||||
| import { Tag, Search as SearchIcon, Info } from "lucide-react" | ||||
| import { Badge } from "@/components/ui/badge" | ||||
|  | ||||
| interface CommandMenuProps { | ||||
| 	icons: { | ||||
| 		name: string | ||||
| 		data: { | ||||
| 			categories: string[] | ||||
| 			aliases: string[] | ||||
| 			[key: string]: unknown | ||||
| 		} | ||||
| 	}[] | ||||
| 	icons: IconWithName[] | ||||
| 	triggerButtonId?: string | ||||
| 	open?: boolean | ||||
| 	onOpenChange?: (open: boolean) => void | ||||
| @@ -41,7 +37,12 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO | ||||
| 		[externalOnOpenChange], | ||||
| 	) | ||||
|  | ||||
| 	const filteredIcons = getFilteredIcons(icons, query) | ||||
| 	const filteredIcons = useMemo(() => | ||||
| 		filterAndSortIcons({ icons, query, limit: 20 }), | ||||
| 		[icons, query] | ||||
| 	) | ||||
|  | ||||
| 	const totalIcons = icons.length | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const handleKeyDown = (e: KeyboardEvent) => { | ||||
| @@ -58,81 +59,100 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO | ||||
| 		return () => document.removeEventListener("keydown", handleKeyDown) | ||||
| 	}, [isOpen, setIsOpen]) | ||||
|  | ||||
| 	function getFilteredIcons(iconList: CommandMenuProps["icons"], query: string) { | ||||
| 		if (!query) { | ||||
| 			// Return a limited number of icons when no query is provided | ||||
| 			return iconList.slice(0, 8) | ||||
| 		} | ||||
|  | ||||
| 		// Calculate scores for each icon | ||||
| 		const scoredIcons = iconList.map((icon) => { | ||||
| 			// Calculate scores for different fields | ||||
| 			const nameScore = fuzzySearch(icon.name, query) * 2.0 // Give more weight to name matches | ||||
|  | ||||
| 			// Get max score from aliases | ||||
| 			const aliasScore = | ||||
| 				icon.data.aliases && icon.data.aliases.length > 0 | ||||
| 					? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * 1.8 // Increased weight for aliases | ||||
| 					: 0 | ||||
|  | ||||
| 			// Get max score from categories | ||||
| 			const categoryScore = | ||||
| 				icon.data.categories && icon.data.categories.length > 0 | ||||
| 					? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query))) | ||||
| 					: 0 | ||||
|  | ||||
| 			// Use the highest score | ||||
| 			const score = Math.max(nameScore, aliasScore, categoryScore) | ||||
|  | ||||
| 			return { icon, score, matchedField: score === nameScore ? "name" : score === aliasScore ? "alias" : "category" } | ||||
| 		}) | ||||
|  | ||||
| 		// Filter icons with a minimum score and sort by highest score | ||||
| 		return scoredIcons | ||||
| 			.filter((item) => item.score > 0.3) // Higher threshold for more accurate results | ||||
| 			.sort((a, b) => b.score - a.score) | ||||
| 			.slice(0, 20) // Limit the number of results | ||||
| 			.map((item) => item.icon) | ||||
| 	} | ||||
|  | ||||
| 	const handleSelect = (name: string) => { | ||||
| 		setIsOpen(false) | ||||
| 		router.push(`/icons/${name}`) | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<CommandDialog open={isOpen} onOpenChange={setIsOpen}> | ||||
| 			<CommandInput placeholder="Search for icons by name, category, or purpose..." value={query} onValueChange={setQuery} /> | ||||
| 			<CommandList> | ||||
| 				<CommandEmpty>No matching icons found. Try a different search term or browse all icons.</CommandEmpty> | ||||
| 				<CommandGroup heading="Icons"> | ||||
| 					{filteredIcons.map(({ name, data }) => { | ||||
| 						// Find matched alias for display if available | ||||
| 						const matchedAlias = | ||||
| 							query && data.aliases && data.aliases.length > 0 | ||||
| 								? data.aliases.find((alias) => alias.toLowerCase().includes(query.toLowerCase())) | ||||
| 								: null | ||||
| 						const formatedIconName = formatIconName(name) | ||||
| 	const handleBrowseAll = () => { | ||||
| 		setIsOpen(false) | ||||
| 		router.push("/icons") | ||||
| 	} | ||||
|  | ||||
| 						return ( | ||||
| 							<CommandItem key={name} value={name} onSelect={() => handleSelect(name)} className="flex items-center gap-2 cursor-pointer"> | ||||
| 								<div className="flex-shrink-0 h-5 w-5 relative"> | ||||
| 									<div className="h-5 w-5 bg-rose-100 dark:bg-rose-900/30 rounded-md flex items-center justify-center"> | ||||
| 										<span className="text-[10px] font-medium text-rose-800 dark:text-rose-300">{name.substring(0, 2).toUpperCase()}</span> | ||||
| 	return ( | ||||
| 		<CommandDialog | ||||
| 			open={isOpen} | ||||
| 			onOpenChange={setIsOpen} | ||||
| 			contentClassName="bg-background/90 backdrop-blur-sm border border-border/60" | ||||
| 		> | ||||
| 			<CommandInput | ||||
| 				placeholder={`Search our collection of ${totalIcons} icons by name or category...`} | ||||
| 				value={query} | ||||
| 				onValueChange={setQuery} | ||||
| 			/> | ||||
| 			<CommandList className="max-h-[300px]"> | ||||
| 				{/* Icon Results */} | ||||
| 				<CommandGroup heading="Icons"> | ||||
| 					{filteredIcons.length > 0 && ( | ||||
| 						filteredIcons.map(({ name, data }) => { | ||||
| 							const formatedIconName = formatIconName(name) | ||||
| 							const hasCategories = data.categories && data.categories.length > 0 | ||||
|  | ||||
| 							return ( | ||||
| 								<CommandItem | ||||
| 									key={name} | ||||
| 									value={name} | ||||
| 									onSelect={() => handleSelect(name)} | ||||
| 									className="flex items-center gap-2 cursor-pointer py-1.5" | ||||
| 								> | ||||
| 									<div className="flex-shrink-0 h-5 w-5 relative"> | ||||
| 										<div className="h-full w-full bg-primary/10 dark:bg-primary/20 rounded-md flex items-center justify-center"> | ||||
| 											<span className="text-[9px] font-medium text-primary dark:text-primary-foreground">{name.substring(0, 2).toUpperCase()}</span> | ||||
| 										</div> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 								<span className="flex-grow capitalize">{formatedIconName}</span> | ||||
| 								{matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>} | ||||
| 								{!matchedAlias && data.categories && data.categories.length > 0 && ( | ||||
| 									<span className="text-xs text-muted-foreground truncate max-w-[100px]"> | ||||
| 										{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} | ||||
| 									</span> | ||||
| 								)} | ||||
| 							</CommandItem> | ||||
| 						) | ||||
| 					})} | ||||
| 									<span className="flex-grow capitalize font-medium text-sm">{formatedIconName}</span> | ||||
| 									{hasCategories && ( | ||||
| 										<div className="flex gap-1 items-center flex-shrink-0 overflow-hidden max-w-[40%]"> | ||||
| 											{/* First category */} | ||||
| 											<Badge | ||||
| 												key={data.categories[0]} | ||||
| 												variant="secondary" | ||||
| 												className="text-xs font-normal inline-flex items-center gap-1 whitespace-nowrap max-w-[120px] overflow-hidden" | ||||
| 											> | ||||
| 												<Tag size={8} className="mr-1 flex-shrink-0" /> | ||||
| 												<span className="truncate"> | ||||
| 													{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} | ||||
| 												</span> | ||||
| 											</Badge> | ||||
| 											{/* "+N" badge if more than one category */} | ||||
| 											{data.categories.length > 1 && ( | ||||
| 												<Badge variant="outline" className="text-xs flex-shrink-0"> | ||||
| 													+{data.categories.length - 1} | ||||
| 												</Badge> | ||||
| 											)} | ||||
| 										</div> | ||||
| 									)} | ||||
| 								</CommandItem> | ||||
| 							) | ||||
| 						}) | ||||
| 					)} | ||||
| 				</CommandGroup> | ||||
| 				<CommandEmpty> | ||||
| 					{/* Minimal empty state */} | ||||
| 					<div className="py-2 px-2 text-center text-xs text-muted-foreground flex items-center justify-center gap-1.5"> | ||||
| 						<Info className="h-3.5 w-3.5 text-destructive" /> {/* Smaller red icon */} | ||||
| 						<span>No matching icons found.</span> | ||||
| 					</div> | ||||
| 				</CommandEmpty> | ||||
| 			</CommandList> | ||||
|  | ||||
| 			{/* Separator and Browse section - Styled div outside CommandList */} | ||||
| 			<div className="border-t border-border/40 pt-1 mt-1 px-1 pb-1"> | ||||
| 				<div | ||||
| 					role="button" | ||||
| 					tabIndex={0} | ||||
| 					className="flex items-center gap-2 cursor-pointer rounded-sm px-2 py-1 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground" | ||||
| 					onClick={handleBrowseAll} | ||||
| 					onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleBrowseAll() }} | ||||
| 				> | ||||
| 					<div className="flex-shrink-0 h-5 w-5 relative"> | ||||
| 						<div className="h-full w-full bg-primary/80 dark:bg-primary/40 rounded-md flex items-center justify-center"> | ||||
| 							<SearchIcon className="text-primary-foreground dark:text-primary-200 w-3.5 h-3.5" /> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<span className="flex-grow text-sm">Browse all icons – {totalIcons} available</span> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</CommandDialog> | ||||
| 	) | ||||
| } | ||||
|   | ||||
| @@ -24,8 +24,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation" | ||||
| import posthog from "posthog-js" | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react" | ||||
| import { toast } from "sonner" | ||||
|  | ||||
| type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest" | ||||
| import { filterAndSortIcons, SortOption } from "@/lib/utils" | ||||
|  | ||||
| export function IconSearch({ icons }: IconSearchProps) { | ||||
| 	const searchParams = useSearchParams() | ||||
| @@ -61,54 +60,6 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 		return Array.from(categories).sort() | ||||
| 	}, [icons]) | ||||
|  | ||||
| 	// Simple filter function using substring matching | ||||
| 	const filterIcons = useCallback( | ||||
| 		(query: string, categories: string[], sort: SortOption) => { | ||||
| 			// First filter by categories if any are selected | ||||
| 			let filtered = icons | ||||
| 			if (categories.length > 0) { | ||||
| 				filtered = filtered.filter(({ data }) => | ||||
| 					data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())), | ||||
| 				) | ||||
| 			} | ||||
|  | ||||
| 			// Then filter by search query | ||||
| 			if (query.trim()) { | ||||
| 				// Normalization function: lowercase, remove spaces and hyphens | ||||
| 				const normalizeString = (str: string) => str.toLowerCase().replace(/[-\s]/g, "") | ||||
| 				const normalizedQuery = normalizeString(query) | ||||
|  | ||||
| 				filtered = filtered.filter(({ name, data }) => { | ||||
| 					// Check normalized name | ||||
| 					if (normalizeString(name).includes(normalizedQuery)) return true | ||||
| 					// Check normalized aliases | ||||
| 					if (data.aliases.some((alias) => normalizeString(alias).includes(normalizedQuery))) return true | ||||
| 					// Check normalized categories | ||||
| 					if (data.categories.some((category) => normalizeString(category).includes(normalizedQuery))) return true | ||||
| 					return false | ||||
| 				}) | ||||
| 			} | ||||
|  | ||||
| 			// Apply sorting | ||||
| 			if (sort === "alphabetical-asc") { | ||||
| 				return filtered.sort((a, b) => a.name.localeCompare(b.name)) | ||||
| 			} | ||||
| 			if (sort === "alphabetical-desc") { | ||||
| 				return filtered.sort((a, b) => b.name.localeCompare(a.name)) | ||||
| 			} | ||||
| 			if (sort === "newest") { | ||||
| 				return filtered.sort((a, b) => { | ||||
| 					return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime() | ||||
| 				}) | ||||
| 			} | ||||
|  | ||||
| 			// Default sort (relevance or fallback to alphabetical) | ||||
| 			// TODO: Implement actual relevance sorting | ||||
| 			return filtered.sort((a, b) => a.name.localeCompare(b.name)) | ||||
| 		}, | ||||
| 		[icons], | ||||
| 	) | ||||
|  | ||||
| 	// Find matched aliases for display purposes | ||||
| 	const matchedAliases = useMemo(() => { | ||||
| 		if (!searchQuery.trim()) return {} | ||||
| @@ -131,8 +82,13 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
|  | ||||
| 	// Use useMemo for filtered icons with debounced query | ||||
| 	const filteredIcons = useMemo(() => { | ||||
| 		return filterIcons(debouncedQuery, selectedCategories, sortOption) | ||||
| 	}, [filterIcons, debouncedQuery, selectedCategories, sortOption]) | ||||
| 		return filterAndSortIcons({ | ||||
| 			icons, | ||||
| 			query: debouncedQuery, | ||||
| 			categories: selectedCategories, | ||||
| 			sort: sortOption, | ||||
| 		}) | ||||
| 	}, [icons, debouncedQuery, selectedCategories, sortOption]) | ||||
|  | ||||
| 	const updateResults = useCallback( | ||||
| 		(query: string, categories: string[], sort: SortOption) => { | ||||
|   | ||||
| @@ -33,10 +33,12 @@ function CommandDialog({ | ||||
|   title = "Command Palette", | ||||
|   description = "Search for a command to run...", | ||||
|   children, | ||||
|   contentClassName, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof Dialog> & { | ||||
|   title?: string | ||||
|   description?: string | ||||
|   contentClassName?: string | ||||
| }) { | ||||
|   return ( | ||||
|     <Dialog {...props}> | ||||
| @@ -44,7 +46,7 @@ function CommandDialog({ | ||||
|         <DialogTitle>{title}</DialogTitle> | ||||
|         <DialogDescription>{description}</DialogDescription> | ||||
|       </DialogHeader> | ||||
|       <DialogContent className="overflow-hidden p-0"> | ||||
|       <DialogContent className={cn("overflow-hidden p-0", contentClassName)}> | ||||
|         <Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> | ||||
|           {children} | ||||
|         </Command> | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { type ClassValue, clsx } from "clsx" | ||||
| import { twMerge } from "tailwind-merge" | ||||
| import type { IconWithName } from "@/types/icons" | ||||
|  | ||||
| export function cn(...inputs: ClassValue[]) { | ||||
| 	return twMerge(clsx(inputs)) | ||||
| @@ -84,46 +85,153 @@ export function containsCharsInOrder(str: string, query: string): number { | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Advanced fuzzy search with multiple scoring methods | ||||
|  * Returns a score from 0-1, where 1 is a perfect match | ||||
|  * Advanced fuzzy search with composite scoring and bonuses: | ||||
|  * - Bonus for exact, prefix, substring matches (additive) | ||||
|  * - Penalize weak matches | ||||
|  * - Require all query words to be present somewhere for multi-word queries | ||||
|  * - Returns composite score (0-1+) | ||||
|  */ | ||||
| export function fuzzySearch(text: string, query: string): number { | ||||
| 	if (!query) return 1 | ||||
| 	if (!text) return 0 | ||||
|  | ||||
| 	// Direct inclusion check (highest priority) | ||||
| 	const normalizedText = text.toLowerCase() | ||||
| 	const normalizedQuery = query.toLowerCase() | ||||
|  | ||||
| 	if (normalizedText === normalizedQuery) return 1 | ||||
| 	if (normalizedText.includes(normalizedQuery)) return 0.9 | ||||
| 	let score = 0 | ||||
|  | ||||
| 	// Check for character sequence matches | ||||
| 	// Bonuses for strong matches | ||||
| 	if (normalizedText === normalizedQuery) score += 1.0 | ||||
| 	else if (normalizedText.startsWith(normalizedQuery)) score += 0.85 | ||||
| 	else if (normalizedText.includes(normalizedQuery)) score += 0.7 | ||||
|  | ||||
| 	// Sequence, similarity, word match | ||||
| 	const sequenceScore = containsCharsInOrder(normalizedText, normalizedQuery) | ||||
|  | ||||
| 	// Calculate string similarity | ||||
| 	const similarityScore = calculateStringSimilarity(normalizedText, normalizedQuery) | ||||
|  | ||||
| 	// Word-by-word matching for multi-word queries | ||||
| 	// Multi-word query: require all words to be present somewhere | ||||
| 	const textWords = normalizedText.split(/\s+/) | ||||
| 	const queryWords = normalizedQuery.split(/\s+/) | ||||
|  | ||||
| 	let wordMatchCount = 0 | ||||
| 	for (const queryWord of queryWords) { | ||||
| 		for (const textWord of textWords) { | ||||
| 			if ( | ||||
| 				textWord === queryWord || | ||||
| 				textWord.startsWith(queryWord) || | ||||
| 				textWord.includes(queryWord) || | ||||
| 				calculateStringSimilarity(textWord, queryWord) > 0.7 || | ||||
| 				containsCharsInOrder(textWord, queryWord) > 0 | ||||
| 				calculateStringSimilarity(textWord, queryWord) > 0.8 || | ||||
| 				containsCharsInOrder(textWord, queryWord) > 0.5 | ||||
| 			) { | ||||
| 				wordMatchCount++ | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	const allWordsPresent = wordMatchCount === queryWords.length | ||||
| 	const wordMatchScore = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0 | ||||
|  | ||||
| 	// Combine scores with weights | ||||
| 	return Math.max(sequenceScore * 0.3, similarityScore * 0.3, wordMatchScore * 0.4) | ||||
| 	// Composite score | ||||
| 	score += sequenceScore * 0.1 + similarityScore * 0.1 + wordMatchScore * 0.6 | ||||
|  | ||||
| 	// Penalize if not all words present in multi-word query | ||||
| 	if (queryWords.length > 1 && !allWordsPresent) score *= 0.4 | ||||
|  | ||||
| 	// Penalize very weak matches | ||||
| 	if (score < 0.5) score *= 0.3 | ||||
|  | ||||
| 	return score | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Filter and sort icons using advanced fuzzy search, categories, and sort options | ||||
|  * - Tunable weights for name, alias, category | ||||
|  * - Penalize if only category matches | ||||
|  * - Require all query words to be present in at least one field | ||||
|  */ | ||||
| export type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest" | ||||
|  | ||||
| export function filterAndSortIcons({ | ||||
| 	icons, | ||||
| 	query = "", | ||||
| 	categories = [], | ||||
| 	sort = "relevance", | ||||
| 	limit, | ||||
| }: { | ||||
| 	icons: IconWithName[] | ||||
| 	query?: string | ||||
| 	categories?: string[] | ||||
| 	sort?: SortOption | ||||
| 	limit?: number | ||||
| }): IconWithName[] { | ||||
| 	const NAME_WEIGHT = 2.0 | ||||
| 	const ALIAS_WEIGHT = 1.5 | ||||
| 	const CATEGORY_WEIGHT = 1.0 | ||||
| 	const CATEGORY_PENALTY = 0.7 // Penalize if only category matches | ||||
|  | ||||
| 	let filtered = icons | ||||
|  | ||||
| 	// Filter by categories if any are selected | ||||
| 	if (categories.length > 0) { | ||||
| 		filtered = filtered.filter(({ data }) => | ||||
| 			data.categories.some((cat) => | ||||
| 				categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase()), | ||||
| 			), | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	if (query.trim()) { | ||||
| 		const queryWords = query.toLowerCase().split(/\s+/) | ||||
| 		const scored = filtered.map((icon) => { | ||||
| 			const nameScore = fuzzySearch(icon.name, query) * NAME_WEIGHT | ||||
| 			const aliasScore = | ||||
| 				icon.data.aliases && icon.data.aliases.length > 0 | ||||
| 					? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * ALIAS_WEIGHT | ||||
| 					: 0 | ||||
| 			const categoryScore = | ||||
| 				icon.data.categories && icon.data.categories.length > 0 | ||||
| 					? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query))) * CATEGORY_WEIGHT | ||||
| 					: 0 | ||||
|  | ||||
| 			const maxScore = Math.max(nameScore, aliasScore, categoryScore) | ||||
|  | ||||
| 			// Penalize if only category matches | ||||
| 			const onlyCategoryMatch = | ||||
| 				categoryScore > 0.7 && nameScore < 0.5 && aliasScore < 0.5 | ||||
| 			const finalScore = onlyCategoryMatch ? maxScore * CATEGORY_PENALTY : maxScore | ||||
|  | ||||
| 			// Require all query words to be present in at least one field | ||||
| 			const allWordsPresent = queryWords.every((word) => | ||||
| 				icon.name.toLowerCase().includes(word) || | ||||
| 				icon.data.aliases.some((alias) => alias.toLowerCase().includes(word)) || | ||||
| 				icon.data.categories.some((cat) => cat.toLowerCase().includes(word)) | ||||
| 			) | ||||
|  | ||||
| 			return { icon, score: allWordsPresent ? finalScore : finalScore * 0.4 } | ||||
| 		}) | ||||
| 			.filter((item) => item.score > 0.7) | ||||
| 			.sort((a, b) => { | ||||
| 				if (b.score !== a.score) return b.score - a.score | ||||
| 				return a.icon.name.localeCompare(b.icon.name) | ||||
| 			}) | ||||
|  | ||||
| 		filtered = scored.map((item) => item.icon) | ||||
| 	} | ||||
|  | ||||
| 	// Sorting | ||||
| 	if (sort === "alphabetical-asc") { | ||||
| 		filtered = filtered.slice().sort((a, b) => a.name.localeCompare(b.name)) | ||||
| 	} else if (sort === "alphabetical-desc") { | ||||
| 		filtered = filtered.slice().sort((a, b) => b.name.localeCompare(a.name)) | ||||
| 	} else if (sort === "newest") { | ||||
| 		filtered = filtered.slice().sort((a, b) => { | ||||
| 			const aTime = a.data.update?.timestamp ? new Date(a.data.update.timestamp).getTime() : 0 | ||||
| 			const bTime = b.data.update?.timestamp ? new Date(b.data.update.timestamp).getTime() : 0 | ||||
| 			return bTime - aTime | ||||
| 		}) | ||||
| 	} // else: relevance (already sorted by score) | ||||
|  | ||||
| 	if (limit && filtered.length > limit) { | ||||
| 		return filtered.slice(0, limit) | ||||
| 	} | ||||
| 	return filtered | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user