mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-06-28 23:40:21 +08: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:
parent
e7284241c9
commit
8afcb351ef
@ -2,19 +2,15 @@
|
|||||||
|
|
||||||
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query"
|
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 { 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 {
|
interface CommandMenuProps {
|
||||||
icons: {
|
icons: IconWithName[]
|
||||||
name: string
|
|
||||||
data: {
|
|
||||||
categories: string[]
|
|
||||||
aliases: string[]
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}[]
|
|
||||||
triggerButtonId?: string
|
triggerButtonId?: string
|
||||||
open?: boolean
|
open?: boolean
|
||||||
onOpenChange?: (open: boolean) => void
|
onOpenChange?: (open: boolean) => void
|
||||||
@ -41,7 +37,12 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
[externalOnOpenChange],
|
[externalOnOpenChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredIcons = getFilteredIcons(icons, query)
|
const filteredIcons = useMemo(() =>
|
||||||
|
filterAndSortIcons({ icons, query, limit: 20 }),
|
||||||
|
[icons, query]
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalIcons = icons.length
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@ -58,81 +59,100 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
return () => document.removeEventListener("keydown", handleKeyDown)
|
return () => document.removeEventListener("keydown", handleKeyDown)
|
||||||
}, [isOpen, setIsOpen])
|
}, [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) => {
|
const handleSelect = (name: string) => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
router.push(`/icons/${name}`)
|
router.push(`/icons/${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const handleBrowseAll = () => {
|
||||||
<CommandDialog open={isOpen} onOpenChange={setIsOpen}>
|
setIsOpen(false)
|
||||||
<CommandInput placeholder="Search for icons by name, category, or purpose..." value={query} onValueChange={setQuery} />
|
router.push("/icons")
|
||||||
<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)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem key={name} value={name} onSelect={() => handleSelect(name)} className="flex items-center gap-2 cursor-pointer">
|
<CommandDialog
|
||||||
<div className="flex-shrink-0 h-5 w-5 relative">
|
open={isOpen}
|
||||||
<div className="h-5 w-5 bg-rose-100 dark:bg-rose-900/30 rounded-md flex items-center justify-center">
|
onOpenChange={setIsOpen}
|
||||||
<span className="text-[10px] font-medium text-rose-800 dark:text-rose-300">{name.substring(0, 2).toUpperCase()}</span>
|
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>
|
||||||
</div>
|
<span className="flex-grow capitalize font-medium text-sm">{formatedIconName}</span>
|
||||||
<span className="flex-grow capitalize">{formatedIconName}</span>
|
{hasCategories && (
|
||||||
{matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>}
|
<div className="flex gap-1 items-center flex-shrink-0 overflow-hidden max-w-[40%]">
|
||||||
{!matchedAlias && data.categories && data.categories.length > 0 && (
|
{/* First category */}
|
||||||
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
|
<Badge
|
||||||
{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
key={data.categories[0]}
|
||||||
</span>
|
variant="secondary"
|
||||||
)}
|
className="text-xs font-normal inline-flex items-center gap-1 whitespace-nowrap max-w-[120px] overflow-hidden"
|
||||||
</CommandItem>
|
>
|
||||||
)
|
<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>
|
</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>
|
</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>
|
</CommandDialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -24,8 +24,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|||||||
import posthog from "posthog-js"
|
import posthog from "posthog-js"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { filterAndSortIcons, SortOption } from "@/lib/utils"
|
||||||
type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
|
|
||||||
|
|
||||||
export function IconSearch({ icons }: IconSearchProps) {
|
export function IconSearch({ icons }: IconSearchProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
@ -61,54 +60,6 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
return Array.from(categories).sort()
|
return Array.from(categories).sort()
|
||||||
}, [icons])
|
}, [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
|
// Find matched aliases for display purposes
|
||||||
const matchedAliases = useMemo(() => {
|
const matchedAliases = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return {}
|
if (!searchQuery.trim()) return {}
|
||||||
@ -131,8 +82,13 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
|
|
||||||
// Use useMemo for filtered icons with debounced query
|
// Use useMemo for filtered icons with debounced query
|
||||||
const filteredIcons = useMemo(() => {
|
const filteredIcons = useMemo(() => {
|
||||||
return filterIcons(debouncedQuery, selectedCategories, sortOption)
|
return filterAndSortIcons({
|
||||||
}, [filterIcons, debouncedQuery, selectedCategories, sortOption])
|
icons,
|
||||||
|
query: debouncedQuery,
|
||||||
|
categories: selectedCategories,
|
||||||
|
sort: sortOption,
|
||||||
|
})
|
||||||
|
}, [icons, debouncedQuery, selectedCategories, sortOption])
|
||||||
|
|
||||||
const updateResults = useCallback(
|
const updateResults = useCallback(
|
||||||
(query: string, categories: string[], sort: SortOption) => {
|
(query: string, categories: string[], sort: SortOption) => {
|
||||||
|
@ -33,10 +33,12 @@ function CommandDialog({
|
|||||||
title = "Command Palette",
|
title = "Command Palette",
|
||||||
description = "Search for a command to run...",
|
description = "Search for a command to run...",
|
||||||
children,
|
children,
|
||||||
|
contentClassName,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
title?: string
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
|
contentClassName?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
@ -44,7 +46,7 @@ function CommandDialog({
|
|||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</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">
|
<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}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import type { IconWithName } from "@/types/icons"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@ -84,46 +85,153 @@ export function containsCharsInOrder(str: string, query: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advanced fuzzy search with multiple scoring methods
|
* Advanced fuzzy search with composite scoring and bonuses:
|
||||||
* Returns a score from 0-1, where 1 is a perfect match
|
* - 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 {
|
export function fuzzySearch(text: string, query: string): number {
|
||||||
if (!query) return 1
|
if (!query) return 1
|
||||||
if (!text) return 0
|
if (!text) return 0
|
||||||
|
|
||||||
// Direct inclusion check (highest priority)
|
|
||||||
const normalizedText = text.toLowerCase()
|
const normalizedText = text.toLowerCase()
|
||||||
const normalizedQuery = query.toLowerCase()
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
|
||||||
if (normalizedText === normalizedQuery) return 1
|
let score = 0
|
||||||
if (normalizedText.includes(normalizedQuery)) return 0.9
|
|
||||||
|
|
||||||
// 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)
|
const sequenceScore = containsCharsInOrder(normalizedText, normalizedQuery)
|
||||||
|
|
||||||
// Calculate string similarity
|
|
||||||
const similarityScore = calculateStringSimilarity(normalizedText, normalizedQuery)
|
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 textWords = normalizedText.split(/\s+/)
|
||||||
const queryWords = normalizedQuery.split(/\s+/)
|
const queryWords = normalizedQuery.split(/\s+/)
|
||||||
|
|
||||||
let wordMatchCount = 0
|
let wordMatchCount = 0
|
||||||
for (const queryWord of queryWords) {
|
for (const queryWord of queryWords) {
|
||||||
for (const textWord of textWords) {
|
for (const textWord of textWords) {
|
||||||
if (
|
if (
|
||||||
|
textWord === queryWord ||
|
||||||
|
textWord.startsWith(queryWord) ||
|
||||||
textWord.includes(queryWord) ||
|
textWord.includes(queryWord) ||
|
||||||
calculateStringSimilarity(textWord, queryWord) > 0.7 ||
|
calculateStringSimilarity(textWord, queryWord) > 0.8 ||
|
||||||
containsCharsInOrder(textWord, queryWord) > 0
|
containsCharsInOrder(textWord, queryWord) > 0.5
|
||||||
) {
|
) {
|
||||||
wordMatchCount++
|
wordMatchCount++
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const allWordsPresent = wordMatchCount === queryWords.length
|
||||||
const wordMatchScore = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0
|
const wordMatchScore = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0
|
||||||
|
|
||||||
// Combine scores with weights
|
// Composite score
|
||||||
return Math.max(sequenceScore * 0.3, similarityScore * 0.3, wordMatchScore * 0.4)
|
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
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user