diff --git a/web/src/components/command-menu.tsx b/web/src/components/command-menu.tsx index dc955062..e954f530 100644 --- a/web/src/components/command-menu.tsx +++ b/web/src/components/command-menu.tsx @@ -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 ( - - - - No matching icons found. Try a different search term or browse all 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 ( - handleSelect(name)} className="flex items-center gap-2 cursor-pointer"> -
-
- {name.substring(0, 2).toUpperCase()} + return ( + + + + {/* Icon Results */} + + {filteredIcons.length > 0 && ( + filteredIcons.map(({ name, data }) => { + const formatedIconName = formatIconName(name) + const hasCategories = data.categories && data.categories.length > 0 + + return ( + handleSelect(name)} + className="flex items-center gap-2 cursor-pointer py-1.5" + > +
+
+ {name.substring(0, 2).toUpperCase()} +
-
- {formatedIconName} - {matchedAlias && alias: {matchedAlias}} - {!matchedAlias && data.categories && data.categories.length > 0 && ( - - {data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} - - )} - - ) - })} + {formatedIconName} + {hasCategories && ( +
+ {/* First category */} + + + + {data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} + + + {/* "+N" badge if more than one category */} + {data.categories.length > 1 && ( + + +{data.categories.length - 1} + + )} +
+ )} + + ) + }) + )} + + {/* Minimal empty state */} +
+ {/* Smaller red icon */} + No matching icons found. +
+
+ + {/* Separator and Browse section - Styled div outside CommandList */} +
+
{ if (e.key === 'Enter' || e.key === ' ') handleBrowseAll() }} + > +
+
+ +
+
+ Browse all icons – {totalIcons} available +
+
) } diff --git a/web/src/components/icon-search.tsx b/web/src/components/icon-search.tsx index fd5fd3f7..94c19bba 100644 --- a/web/src/components/icon-search.tsx +++ b/web/src/components/icon-search.tsx @@ -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) => { diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx index 4ca53497..24d046c0 100644 --- a/web/src/components/ui/command.tsx +++ b/web/src/components/ui/command.tsx @@ -33,10 +33,12 @@ function CommandDialog({ title = "Command Palette", description = "Search for a command to run...", children, + contentClassName, ...props }: React.ComponentProps & { title?: string description?: string + contentClassName?: string }) { return ( @@ -44,7 +46,7 @@ function CommandDialog({ {title} {description} - + {children} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index b5855d76..52394f7a 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -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 }