2025-04-26 13:07:02 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
2025-04-27 22:59:33 +02:00
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
2025-04-26 13:07:02 +02:00
|
|
|
|
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
|
|
|
|
import { useMediaQuery } from "@/hooks/use-media-query"
|
2025-04-27 22:59:33 +02:00
|
|
|
|
import { filterAndSortIcons, formatIconName, fuzzySearch } from "@/lib/utils"
|
2025-04-26 23:07:05 +02:00
|
|
|
|
import type { IconWithName } from "@/types/icons"
|
2025-04-27 22:59:33 +02:00
|
|
|
|
import { Info, Search as SearchIcon, Tag } from "lucide-react"
|
|
|
|
|
import { useRouter } from "next/navigation"
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
2025-04-26 13:07:02 +02:00
|
|
|
|
|
|
|
|
|
interface CommandMenuProps {
|
2025-04-26 23:07:05 +02:00
|
|
|
|
icons: IconWithName[]
|
2025-04-26 13:07:02 +02:00
|
|
|
|
triggerButtonId?: string
|
|
|
|
|
open?: boolean
|
|
|
|
|
onOpenChange?: (open: boolean) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalOnOpenChange }: CommandMenuProps) {
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
const [internalOpen, setInternalOpen] = useState(false)
|
|
|
|
|
const [query, setQuery] = useState("")
|
|
|
|
|
const isDesktop = useMediaQuery("(min-width: 768px)")
|
|
|
|
|
|
|
|
|
|
// Use either external or internal state for controlling open state
|
|
|
|
|
const isOpen = externalOpen !== undefined ? externalOpen : internalOpen
|
|
|
|
|
|
|
|
|
|
// Wrap setIsOpen in useCallback to fix dependency issue
|
|
|
|
|
const setIsOpen = useCallback(
|
|
|
|
|
(value: boolean) => {
|
|
|
|
|
if (externalOnOpenChange) {
|
|
|
|
|
externalOnOpenChange(value)
|
|
|
|
|
} else {
|
|
|
|
|
setInternalOpen(value)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[externalOnOpenChange],
|
|
|
|
|
)
|
|
|
|
|
|
2025-04-27 22:59:33 +02:00
|
|
|
|
const filteredIcons = useMemo(() => filterAndSortIcons({ icons, query, limit: 20 }), [icons, query])
|
2025-04-26 23:07:05 +02:00
|
|
|
|
|
|
|
|
|
const totalIcons = icons.length
|
2025-04-26 13:07:02 +02:00
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
if (
|
|
|
|
|
(e.key === "k" && (e.metaKey || e.ctrlKey)) ||
|
|
|
|
|
(e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA")
|
|
|
|
|
) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
setIsOpen(!isOpen)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener("keydown", handleKeyDown)
|
|
|
|
|
return () => document.removeEventListener("keydown", handleKeyDown)
|
|
|
|
|
}, [isOpen, setIsOpen])
|
|
|
|
|
|
|
|
|
|
const handleSelect = (name: string) => {
|
|
|
|
|
setIsOpen(false)
|
|
|
|
|
router.push(`/icons/${name}`)
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-26 23:07:05 +02:00
|
|
|
|
const handleBrowseAll = () => {
|
|
|
|
|
setIsOpen(false)
|
|
|
|
|
router.push("/icons")
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-26 13:07:02 +02:00
|
|
|
|
return (
|
2025-04-27 22:59:33 +02:00
|
|
|
|
<CommandDialog open={isOpen} onOpenChange={setIsOpen} contentClassName="bg-background/90 backdrop-blur-sm border border-border/60">
|
2025-04-26 23:07:05 +02:00
|
|
|
|
<CommandInput
|
|
|
|
|
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
|
|
|
|
|
value={query}
|
|
|
|
|
onValueChange={setQuery}
|
|
|
|
|
/>
|
|
|
|
|
<CommandList className="max-h-[300px]">
|
|
|
|
|
{/* Icon Results */}
|
2025-04-26 13:07:02 +02:00
|
|
|
|
<CommandGroup heading="Icons">
|
2025-04-27 22:59:33 +02:00
|
|
|
|
{filteredIcons.length > 0 &&
|
2025-04-26 23:07:05 +02:00
|
|
|
|
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">
|
2025-04-27 22:59:33 +02:00
|
|
|
|
<span className="text-[9px] font-medium text-primary dark:text-primary-foreground">
|
|
|
|
|
{name.substring(0, 2).toUpperCase()}
|
|
|
|
|
</span>
|
2025-04-26 23:07:05 +02:00
|
|
|
|
</div>
|
2025-04-26 13:07:02 +02:00
|
|
|
|
</div>
|
2025-04-26 23:07:05 +02:00
|
|
|
|
<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" />
|
2025-04-27 22:59:33 +02:00
|
|
|
|
<span className="truncate">{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}</span>
|
2025-04-26 23:07:05 +02:00
|
|
|
|
</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>
|
|
|
|
|
)
|
2025-04-27 22:59:33 +02:00
|
|
|
|
})}
|
2025-04-26 13:07:02 +02:00
|
|
|
|
</CommandGroup>
|
2025-04-26 23:07:05 +02:00
|
|
|
|
<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>
|
2025-04-26 13:07:02 +02:00
|
|
|
|
</CommandList>
|
2025-04-26 23:07:05 +02:00
|
|
|
|
|
|
|
|
|
{/* Separator and Browse section - Styled div outside CommandList */}
|
|
|
|
|
<div className="border-t border-border/40 pt-1 mt-1 px-1 pb-1">
|
2025-04-27 22:59:33 +02:00
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
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"
|
2025-04-26 23:07:05 +02:00
|
|
|
|
onClick={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>
|
2025-04-27 22:59:33 +02:00
|
|
|
|
</button>
|
2025-04-26 23:07:05 +02:00
|
|
|
|
</div>
|
2025-04-26 13:07:02 +02:00
|
|
|
|
</CommandDialog>
|
|
|
|
|
)
|
|
|
|
|
}
|