Merge pull request #1320 from homarr-labs/feat/related-refine

This commit is contained in:
Thomas Camlong 2025-04-28 16:21:21 +02:00 committed by GitHub
commit 321e969f6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 102 additions and 81 deletions

View File

@ -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>
) )

View File

@ -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>
) )
} }

View File

@ -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()

View File

@ -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 }