mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-06-28 23:40:21 +08:00
Merge pull request #1320 from homarr-labs/feat/related-refine
This commit is contained in:
commit
321e969f6c
@ -1,13 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
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, filterAndSortIcons } from "@/lib/utils"
|
import { filterAndSortIcons, formatIconName, fuzzySearch } from "@/lib/utils"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { useCallback, useEffect, useState, useMemo } from "react"
|
|
||||||
import type { IconWithName } from "@/types/icons"
|
import type { IconWithName } from "@/types/icons"
|
||||||
import { Tag, Search as SearchIcon, Info } from "lucide-react"
|
import { Info, Search as SearchIcon, Tag } from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
|
|
||||||
interface CommandMenuProps {
|
interface CommandMenuProps {
|
||||||
icons: IconWithName[]
|
icons: IconWithName[]
|
||||||
@ -37,10 +37,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
[externalOnOpenChange],
|
[externalOnOpenChange],
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredIcons = useMemo(() =>
|
const filteredIcons = useMemo(() => filterAndSortIcons({ icons, query, limit: 20 }), [icons, query])
|
||||||
filterAndSortIcons({ icons, query, limit: 20 }),
|
|
||||||
[icons, query]
|
|
||||||
)
|
|
||||||
|
|
||||||
const totalIcons = icons.length
|
const totalIcons = icons.length
|
||||||
|
|
||||||
@ -70,11 +67,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog
|
<CommandDialog open={isOpen} onOpenChange={setIsOpen} contentClassName="bg-background/90 backdrop-blur-sm border border-border/60">
|
||||||
open={isOpen}
|
|
||||||
onOpenChange={setIsOpen}
|
|
||||||
contentClassName="bg-background/90 backdrop-blur-sm border border-border/60"
|
|
||||||
>
|
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
|
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
|
||||||
value={query}
|
value={query}
|
||||||
@ -83,7 +76,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
<CommandList className="max-h-[300px]">
|
<CommandList className="max-h-[300px]">
|
||||||
{/* Icon Results */}
|
{/* Icon Results */}
|
||||||
<CommandGroup heading="Icons">
|
<CommandGroup heading="Icons">
|
||||||
{filteredIcons.length > 0 && (
|
{filteredIcons.length > 0 &&
|
||||||
filteredIcons.map(({ name, data }) => {
|
filteredIcons.map(({ name, data }) => {
|
||||||
const formatedIconName = formatIconName(name)
|
const formatedIconName = formatIconName(name)
|
||||||
const hasCategories = data.categories && data.categories.length > 0
|
const hasCategories = data.categories && data.categories.length > 0
|
||||||
@ -97,7 +90,9 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
>
|
>
|
||||||
<div className="flex-shrink-0 h-5 w-5 relative">
|
<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">
|
<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>
|
<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 font-medium text-sm">{formatedIconName}</span>
|
||||||
@ -110,9 +105,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
className="text-xs font-normal inline-flex items-center gap-1 whitespace-nowrap max-w-[120px] overflow-hidden"
|
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" />
|
<Tag size={8} className="mr-1 flex-shrink-0" />
|
||||||
<span className="truncate">
|
<span className="truncate">{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}</span>
|
||||||
{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
|
|
||||||
</span>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* "+N" badge if more than one category */}
|
{/* "+N" badge if more than one category */}
|
||||||
{data.categories.length > 1 && (
|
{data.categories.length > 1 && (
|
||||||
@ -124,8 +117,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
)}
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
)}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
{/* Minimal empty state */}
|
{/* Minimal empty state */}
|
||||||
@ -138,12 +130,10 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
|
|
||||||
{/* Separator and Browse section - Styled div outside 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 className="border-t border-border/40 pt-1 mt-1 px-1 pb-1">
|
||||||
<div
|
<button
|
||||||
role="button"
|
type="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 w-full"
|
||||||
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}
|
onClick={handleBrowseAll}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleBrowseAll() }}
|
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0 h-5 w-5 relative">
|
<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">
|
<div className="h-full w-full bg-primary/80 dark:bg-primary/40 rounded-md flex items-center justify-center">
|
||||||
@ -151,7 +141,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="flex-grow text-sm">Browse all icons – {totalIcons} available</span>
|
<span className="flex-grow text-sm">Browse all icons – {totalIcons} available</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</CommandDialog>
|
</CommandDialog>
|
||||||
)
|
)
|
||||||
|
@ -10,7 +10,7 @@ import { formatIconName } from "@/lib/utils"
|
|||||||
import type { AuthorData, Icon, IconFile } from "@/types/icons"
|
import type { AuthorData, Icon, IconFile } from "@/types/icons"
|
||||||
import confetti from "canvas-confetti"
|
import confetti from "canvas-confetti"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
|
import { ArrowRight, Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@ -479,7 +479,32 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{iconData.categories && iconData.categories.length > 0 && (
|
{iconData.categories &&
|
||||||
|
iconData.categories.length > 0 &&
|
||||||
|
(() => {
|
||||||
|
const MAX_RELATED_ICONS = 16
|
||||||
|
const currentCategories = iconData.categories || []
|
||||||
|
|
||||||
|
const relatedIconsWithScore = Object.entries(allIcons)
|
||||||
|
.map(([name, data]) => {
|
||||||
|
if (name === icon) return null // Exclude the current icon
|
||||||
|
|
||||||
|
const otherCategories = data.categories || []
|
||||||
|
const commonCategories = currentCategories.filter((cat) => otherCategories.includes(cat))
|
||||||
|
const score = commonCategories.length
|
||||||
|
|
||||||
|
return score > 0 ? { name, data, score } : null
|
||||||
|
})
|
||||||
|
.filter((item): item is { name: string; data: Icon; score: number } => item !== null) // Type guard
|
||||||
|
.sort((a, b) => b.score - a.score) // Sort by score DESC
|
||||||
|
|
||||||
|
const topRelatedIcons = relatedIconsWithScore.slice(0, MAX_RELATED_ICONS)
|
||||||
|
|
||||||
|
const viewMoreUrl = `/icons?${currentCategories.map((cat) => `category=${encodeURIComponent(cat)}`).join("&")}`
|
||||||
|
|
||||||
|
if (topRelatedIcons.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
<section className="container mx-auto mt-12" aria-labelledby="related-icons-title">
|
<section className="container mx-auto mt-12" aria-labelledby="related-icons-title">
|
||||||
<Card className="bg-background/50 border shadow-lg">
|
<Card className="bg-background/50 border shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -487,23 +512,30 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
|||||||
<h2 id="related-icons-title">Related Icons</h2>
|
<h2 id="related-icons-title">Related Icons</h2>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Other icons from {iconData.categories.map((cat) => cat.replace(/-/g, " ")).join(", ")} categories
|
Other icons from {currentCategories.map((cat) => cat.replace(/-/g, " ")).join(", ")} categories
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<IconsGrid
|
<IconsGrid filteredIcons={topRelatedIcons} matchedAliases={{}} />
|
||||||
filteredIcons={Object.entries(allIcons)
|
{relatedIconsWithScore.length > MAX_RELATED_ICONS && (
|
||||||
.filter(([name, data]) => {
|
<div className="mt-6 text-center">
|
||||||
if (name === icon) return false
|
<Button
|
||||||
return data.categories?.some((cat) => iconData.categories?.includes(cat))
|
asChild
|
||||||
})
|
variant="link"
|
||||||
.map(([name, data]) => ({ name, data }))}
|
className="text-muted-foreground hover:text-primary transition-colors duration-200 hover:no-underline"
|
||||||
matchedAliases={{}}
|
>
|
||||||
/>
|
<Link href={viewMoreUrl} className="no-underline">
|
||||||
|
View all related icons
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { type SortOption, filterAndSortIcons } from "@/lib/utils"
|
||||||
import type { IconSearchProps } from "@/types/icons"
|
import type { IconSearchProps } from "@/types/icons"
|
||||||
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
|
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
@ -24,7 +25,6 @@ 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"
|
|
||||||
|
|
||||||
export function IconSearch({ icons }: IconSearchProps) {
|
export function IconSearch({ icons }: IconSearchProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import type { IconWithName } from "@/types/icons"
|
||||||
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))
|
||||||
@ -173,15 +173,14 @@ export function filterAndSortIcons({
|
|||||||
// Filter by categories if any are selected
|
// Filter by categories if any are selected
|
||||||
if (categories.length > 0) {
|
if (categories.length > 0) {
|
||||||
filtered = filtered.filter(({ data }) =>
|
filtered = filtered.filter(({ data }) =>
|
||||||
data.categories.some((cat) =>
|
data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())),
|
||||||
categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase()),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
const queryWords = query.toLowerCase().split(/\s+/)
|
const queryWords = query.toLowerCase().split(/\s+/)
|
||||||
const scored = filtered.map((icon) => {
|
const scored = filtered
|
||||||
|
.map((icon) => {
|
||||||
const nameScore = fuzzySearch(icon.name, query) * NAME_WEIGHT
|
const nameScore = fuzzySearch(icon.name, query) * NAME_WEIGHT
|
||||||
const aliasScore =
|
const aliasScore =
|
||||||
icon.data.aliases && icon.data.aliases.length > 0
|
icon.data.aliases && icon.data.aliases.length > 0
|
||||||
@ -195,15 +194,15 @@ export function filterAndSortIcons({
|
|||||||
const maxScore = Math.max(nameScore, aliasScore, categoryScore)
|
const maxScore = Math.max(nameScore, aliasScore, categoryScore)
|
||||||
|
|
||||||
// Penalize if only category matches
|
// Penalize if only category matches
|
||||||
const onlyCategoryMatch =
|
const onlyCategoryMatch = categoryScore > 0.7 && nameScore < 0.5 && aliasScore < 0.5
|
||||||
categoryScore > 0.7 && nameScore < 0.5 && aliasScore < 0.5
|
|
||||||
const finalScore = onlyCategoryMatch ? maxScore * CATEGORY_PENALTY : maxScore
|
const finalScore = onlyCategoryMatch ? maxScore * CATEGORY_PENALTY : maxScore
|
||||||
|
|
||||||
// Require all query words to be present in at least one field
|
// Require all query words to be present in at least one field
|
||||||
const allWordsPresent = queryWords.every((word) =>
|
const allWordsPresent = queryWords.every(
|
||||||
|
(word) =>
|
||||||
icon.name.toLowerCase().includes(word) ||
|
icon.name.toLowerCase().includes(word) ||
|
||||||
icon.data.aliases.some((alias) => alias.toLowerCase().includes(word)) ||
|
icon.data.aliases.some((alias) => alias.toLowerCase().includes(word)) ||
|
||||||
icon.data.categories.some((cat) => cat.toLowerCase().includes(word))
|
icon.data.categories.some((cat) => cat.toLowerCase().includes(word)),
|
||||||
)
|
)
|
||||||
|
|
||||||
return { icon, score: allWordsPresent ? finalScore : finalScore * 0.4 }
|
return { icon, score: allWordsPresent ? finalScore : finalScore * 0.4 }
|
||||||
|
Loading…
x
Reference in New Issue
Block a user