dashboard-icons/web/src/components/icon-search.tsx

503 lines
16 KiB
TypeScript
Raw Normal View History

2025-04-17 15:12:28 +02:00
"use client"
import { VirtualizedIconsGrid } from "@/components/icon-grid"
2025-04-17 15:12:28 +02:00
import { IconSubmissionContent } from "@/components/icon-submission-form"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
2025-04-17 07:21:19 +02:00
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
2025-04-17 15:12:28 +02:00
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
2025-04-24 20:38:09 +02:00
import type { IconSearchProps } from "@/types/icons"
2025-04-17 15:12:28 +02:00
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
import { useTheme } from "next-themes"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import posthog from "posthog-js"
2025-04-17 15:12:28 +02:00
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
2025-04-22 17:08:14 +02:00
import { toast } from "sonner"
2025-04-17 15:12:28 +02:00
type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
export function IconSearch({ icons }: IconSearchProps) {
2025-04-17 15:12:28 +02:00
const searchParams = useSearchParams()
const initialQuery = searchParams.get("q")
const initialCategories = searchParams.getAll("category")
const initialSort = (searchParams.get("sort") as SortOption) || "relevance"
const router = useRouter()
const pathname = usePathname()
const [searchQuery, setSearchQuery] = useState(initialQuery ?? "")
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "")
2025-04-17 15:12:28 +02:00
const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? [])
const [sortOption, setSortOption] = useState<SortOption>(initialSort)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const { resolvedTheme } = useTheme()
2025-04-22 17:08:14 +02:00
const [isLazyRequestSubmitted, setIsLazyRequestSubmitted] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery)
}, 200)
return () => clearTimeout(timer)
}, [searchQuery])
2025-04-17 07:21:19 +02:00
// Extract all unique categories
const allCategories = useMemo(() => {
2025-04-17 15:12:28 +02:00
const categories = new Set<string>()
2025-04-17 07:21:19 +02:00
for (const icon of icons) {
for (const category of icon.data.categories) {
2025-04-17 15:12:28 +02:00
categories.add(category)
2025-04-17 07:21:19 +02:00
}
}
2025-04-17 15:12:28 +02:00
return Array.from(categories).sort()
}, [icons])
2025-04-17 07:21:19 +02:00
2025-04-17 15:11:17 +02:00
// Simple filter function using substring matching
const filterIcons = useCallback(
(query: string, categories: string[], sort: SortOption) => {
// First filter by categories if any are selected
2025-04-17 15:12:28 +02:00
let filtered = icons
2025-04-17 15:11:17 +02:00
if (categories.length > 0) {
filtered = filtered.filter(({ data }) =>
2025-04-17 15:12:28 +02:00
data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())),
)
2025-04-17 15:11:17 +02:00
}
2025-04-17 07:21:19 +02:00
2025-04-17 15:11:17 +02:00
// 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)
2025-04-17 15:11:17 +02:00
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
2025-04-17 15:12:28 +02:00
return false
})
2025-04-17 15:11:17 +02:00
}
2025-04-17 07:21:19 +02:00
2025-04-17 15:11:17 +02:00
// Apply sorting
if (sort === "alphabetical-asc") {
2025-04-17 15:12:28 +02:00
return filtered.sort((a, b) => a.name.localeCompare(b.name))
2025-04-17 15:11:17 +02:00
}
if (sort === "alphabetical-desc") {
2025-04-17 15:12:28 +02:00
return filtered.sort((a, b) => b.name.localeCompare(a.name))
2025-04-17 15:11:17 +02:00
}
if (sort === "newest") {
return filtered.sort((a, b) => {
2025-04-17 15:12:28 +02:00
return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime()
})
2025-04-17 15:11:17 +02:00
}
2025-04-17 07:21:19 +02:00
2025-04-17 15:11:17 +02:00
// Default sort (relevance or fallback to alphabetical)
// TODO: Implement actual relevance sorting
2025-04-17 15:12:28 +02:00
return filtered.sort((a, b) => a.name.localeCompare(b.name))
2025-04-17 15:11:17 +02:00
},
[icons],
2025-04-17 15:12:28 +02:00
)
2025-04-17 15:11:17 +02:00
// Find matched aliases for display purposes
const matchedAliases = useMemo(() => {
2025-04-17 15:12:28 +02:00
if (!searchQuery.trim()) return {}
2025-04-17 15:11:17 +02:00
2025-04-17 15:12:28 +02:00
const q = searchQuery.toLowerCase()
const matches: Record<string, string> = {}
2025-04-17 15:11:17 +02:00
2025-04-17 15:12:28 +02:00
for (const { name, data } of icons) {
2025-04-17 15:11:17 +02:00
// If name doesn't match but an alias does, store the first matching alias
if (!name.toLowerCase().includes(q)) {
2025-04-17 15:12:28 +02:00
const matchingAlias = data.aliases.find((alias) => alias.toLowerCase().includes(q))
2025-04-17 15:11:17 +02:00
if (matchingAlias) {
2025-04-17 15:12:28 +02:00
matches[name] = matchingAlias
2025-04-17 07:21:19 +02:00
}
}
2025-04-17 15:12:28 +02:00
}
2025-04-17 15:12:28 +02:00
return matches
}, [icons, searchQuery])
2025-04-17 07:21:19 +02:00
// Use useMemo for filtered icons with debounced query
2025-04-17 15:11:17 +02:00
const filteredIcons = useMemo(() => {
return filterIcons(debouncedQuery, selectedCategories, sortOption)
}, [filterIcons, debouncedQuery, selectedCategories, sortOption])
2025-04-17 07:21:19 +02:00
const updateResults = useCallback(
2025-04-17 07:21:19 +02:00
(query: string, categories: string[], sort: SortOption) => {
2025-04-17 15:12:28 +02:00
const params = new URLSearchParams()
if (query) params.set("q", query)
2025-04-17 07:21:19 +02:00
// Clear existing category params and add new ones
for (const category of categories) {
2025-04-17 15:12:28 +02:00
params.append("category", category)
2025-04-17 07:21:19 +02:00
}
2025-04-17 07:21:19 +02:00
// Add sort parameter if not default
if (sort !== "relevance" || initialSort !== "relevance") {
2025-04-17 15:12:28 +02:00
params.set("sort", sort)
2025-04-17 07:21:19 +02:00
}
2025-04-17 15:12:28 +02:00
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname
router.push(newUrl, { scroll: false })
},
2025-04-17 15:11:17 +02:00
[pathname, router, initialSort],
2025-04-17 15:12:28 +02:00
)
2025-04-17 07:21:19 +02:00
const handleSearch = useCallback(
(query: string) => {
2025-04-17 15:12:28 +02:00
setSearchQuery(query)
if (timeoutRef.current) {
2025-04-17 15:12:28 +02:00
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
2025-04-17 15:12:28 +02:00
updateResults(query, selectedCategories, sortOption)
}, 200) // Changed from 100ms to 200ms
},
2025-04-17 07:21:19 +02:00
[updateResults, selectedCategories, sortOption],
2025-04-17 15:12:28 +02:00
)
2025-04-17 07:21:19 +02:00
const handleCategoryChange = useCallback(
(category: string) => {
2025-04-17 15:12:28 +02:00
let newCategories: string[]
2025-04-17 07:21:19 +02:00
if (selectedCategories.includes(category)) {
// Remove the category if it's already selected
2025-04-17 15:12:28 +02:00
newCategories = selectedCategories.filter((c) => c !== category)
2025-04-17 07:21:19 +02:00
} else {
// Add the category if it's not selected
2025-04-17 15:12:28 +02:00
newCategories = [...selectedCategories, category]
2025-04-17 07:21:19 +02:00
}
2025-04-17 15:12:28 +02:00
setSelectedCategories(newCategories)
updateResults(searchQuery, newCategories, sortOption)
2025-04-17 07:21:19 +02:00
},
[updateResults, searchQuery, selectedCategories, sortOption],
2025-04-17 15:12:28 +02:00
)
2025-04-17 07:21:19 +02:00
const handleSortChange = useCallback(
(sort: SortOption) => {
2025-04-17 15:12:28 +02:00
setSortOption(sort)
updateResults(searchQuery, selectedCategories, sort)
2025-04-17 07:21:19 +02:00
},
[updateResults, searchQuery, selectedCategories],
2025-04-17 15:12:28 +02:00
)
2025-04-17 07:21:19 +02:00
const clearFilters = useCallback(() => {
2025-04-17 15:12:28 +02:00
setSearchQuery("")
setSelectedCategories([])
setSortOption("relevance")
updateResults("", [], "relevance")
}, [updateResults])
2025-04-17 07:21:19 +02:00
useEffect(() => {
return () => {
if (timeoutRef.current) {
2025-04-17 15:12:28 +02:00
clearTimeout(timeoutRef.current)
}
2025-04-17 15:12:28 +02:00
}
}, [])
useEffect(() => {
if (filteredIcons.length === 0 && searchQuery) {
console.log("no icons found", {
query: searchQuery,
})
posthog.capture("no icons found", {
query: searchQuery,
})
}
}, [filteredIcons, searchQuery])
2025-04-17 15:12:28 +02:00
if (!searchParams) return null
2025-04-17 07:21:19 +02:00
const getSortLabel = (sort: SortOption) => {
switch (sort) {
case "relevance":
return "Relevance"
2025-04-17 07:21:19 +02:00
case "alphabetical-asc":
return "Name (A-Z)"
2025-04-17 07:21:19 +02:00
case "alphabetical-desc":
return "Name (Z-A)"
2025-04-17 07:21:19 +02:00
case "newest":
2025-04-17 15:12:28 +02:00
return "Newest first"
2025-04-17 07:21:19 +02:00
default:
2025-04-17 15:12:28 +02:00
return "Sort"
2025-04-17 07:21:19 +02:00
}
2025-04-17 15:12:28 +02:00
}
2025-04-17 07:21:19 +02:00
const getSortIcon = (sort: SortOption) => {
switch (sort) {
case "relevance":
2025-04-17 15:12:28 +02:00
return <Search className="h-4 w-4" />
2025-04-17 07:21:19 +02:00
case "alphabetical-asc":
2025-04-17 15:12:28 +02:00
return <ArrowDownAZ className="h-4 w-4" />
2025-04-17 07:21:19 +02:00
case "alphabetical-desc":
2025-04-17 15:12:28 +02:00
return <ArrowUpZA className="h-4 w-4" />
2025-04-17 07:21:19 +02:00
case "newest":
2025-04-17 15:12:28 +02:00
return <Calendar className="h-4 w-4" />
2025-04-17 07:21:19 +02:00
default:
2025-04-17 15:12:28 +02:00
return <SortAsc className="h-4 w-4" />
2025-04-17 07:21:19 +02:00
}
2025-04-17 15:12:28 +02:00
}
2025-04-17 07:21:19 +02:00
return (
<>
2025-04-17 15:11:17 +02:00
<div className="space-y-4 w-full">
2025-04-17 07:21:19 +02:00
{/* Search input */}
<div className="relative w-full">
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground transition-all duration-300">
<Search className="h-4 w-4" />
</div>
<Input
type="search"
placeholder="Search for icons..."
2025-04-17 15:11:17 +02:00
className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm"
2025-04-17 07:21:19 +02:00
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{/* Filter and sort controls */}
<div className="flex flex-wrap gap-2 justify-start">
{/* Filter dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm"
aria-label="Filter icons"
>
2025-04-17 07:21:19 +02:00
<Filter className="h-4 w-4 mr-2" />
<span>{selectedCategories.length > 0 ? `Filters (${selectedCategories.length})` : "Filter"}</span>
2025-04-17 07:21:19 +02:00
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 sm:w-56">
<DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel>
2025-04-17 07:21:19 +02:00
<DropdownMenuSeparator />
<div className="max-h-[40vh] overflow-y-auto p-1">
{allCategories.map((category) => (
<DropdownMenuCheckboxItem
key={category}
checked={selectedCategories.includes(category)}
onCheckedChange={() => handleCategoryChange(category)}
className="cursor-pointer capitalize"
>
2025-04-17 15:12:28 +02:00
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
2025-04-17 07:21:19 +02:00
</DropdownMenuCheckboxItem>
))}
</div>
{selectedCategories.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
2025-04-17 15:12:28 +02:00
setSelectedCategories([])
updateResults(searchQuery, [], sortOption)
2025-04-17 07:21:19 +02:00
}}
2025-04-17 15:11:17 +02:00
className="cursor-pointer focus: focus:bg-rose-50 dark:focus:bg-rose-950/20"
2025-04-17 07:21:19 +02:00
>
Clear categories
2025-04-17 07:21:19 +02:00
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Sort dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
2025-04-18 16:51:04 +02:00
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm">
2025-04-17 07:21:19 +02:00
{getSortIcon(sortOption)}
<span className="ml-2">{getSortLabel(sortOption)}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="font-semibold">Sort Icons</DropdownMenuLabel>
2025-04-17 07:21:19 +02:00
<DropdownMenuSeparator />
2025-04-17 15:12:28 +02:00
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
2025-04-17 07:21:19 +02:00
<Search className="h-4 w-4 mr-2" />
Relevance
2025-04-17 07:21:19 +02:00
</DropdownMenuRadioItem>
2025-04-17 15:12:28 +02:00
<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer">
<ArrowDownAZ className="h-4 w-4 mr-2" />
Name (A-Z)
2025-04-17 07:21:19 +02:00
</DropdownMenuRadioItem>
2025-04-17 15:12:28 +02:00
<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer">
<ArrowUpZA className="h-4 w-4 mr-2" />
Name (Z-A)
2025-04-17 07:21:19 +02:00
</DropdownMenuRadioItem>
2025-04-17 15:12:28 +02:00
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
2025-04-17 07:21:19 +02:00
<Calendar className="h-4 w-4 mr-2" />
Newest first
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Clear all button */}
2025-04-17 15:12:28 +02:00
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
<Button
variant="outline"
size="sm"
onClick={clearFilters}
className="flex-1 sm:flex-none cursor-pointer bg-background"
aria-label="Reset all filters"
>
2025-04-17 07:21:19 +02:00
<X className="h-4 w-4 mr-2" />
<span>Reset</span>
2025-04-17 07:21:19 +02:00
</Button>
)}
</div>
{/* Active filter badges */}
{selectedCategories.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="text-sm text-muted-foreground">Selected:</span>
2025-04-17 07:21:19 +02:00
<div className="flex flex-wrap gap-2">
{selectedCategories.map((category) => (
2025-04-17 15:12:28 +02:00
<Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
2025-04-17 07:21:19 +02:00
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer"
onClick={() => handleCategoryChange(category)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
2025-04-17 15:12:28 +02:00
setSelectedCategories([])
updateResults(searchQuery, [], sortOption)
2025-04-17 07:21:19 +02:00
}}
2025-04-18 16:51:04 +02:00
className="text-xs h-7 px-2 cursor-pointer"
2025-04-17 07:21:19 +02:00
>
Clear
2025-04-17 07:21:19 +02:00
</Button>
</div>
)}
<Separator className="my-2" />
2025-04-17 15:11:17 +02:00
</div>
{filteredIcons.length === 0 ? (
2025-04-22 17:08:14 +02:00
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center">
<div className="text-center">
<h2 className="text-3xl sm:text-5xl font-semibold">Icon not found</h2>
<p className="text-lg text-muted-foreground mt-2">Help us expand our collection</p>
</div>
<div className="flex flex-col gap-4 items-center w-full">
<IconSubmissionContent />
<div className="mt-4 flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">Can't submit it yourself?</span>
<Button
className="cursor-pointer"
variant="outline"
size="sm"
onClick={() => {
setIsLazyRequestSubmitted(true)
toast("Request received!", {
description: `We've noted your request for "${searchQuery || "this icon"}". Thanks for your suggestion.`,
})
posthog.capture("lazy icon request", {
query: searchQuery,
categories: selectedCategories,
})
}}
disabled={isLazyRequestSubmitted}
>
Request this icon
</Button>
</div>
</div>
2025-04-17 15:11:17 +02:00
</div>
) : (
2025-04-17 07:21:19 +02:00
<>
<div className="flex justify-between items-center pb-2">
<p className="text-sm text-muted-foreground">
2025-04-17 15:11:17 +02:00
Found {filteredIcons.length} icon
{filteredIcons.length !== 1 ? "s" : ""}.
2025-04-17 07:21:19 +02:00
</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{getSortIcon(sortOption)}
<span>{getSortLabel(sortOption)}</span>
</div>
</div>
2025-04-17 16:19:42 +02:00
<VirtualizedIconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} />
2025-04-17 07:21:19 +02:00
</>
)}
</>
2025-04-17 15:12:28 +02:00
)
}
function IconCard({
name,
data: iconData,
matchedAlias,
}: {
name: string
data: Icon
matchedAlias?: string | null
}) {
return (
<MagicCard className="rounded-md shadow-md">
<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer">
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
<Image
src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`}
alt={`${name} icon`}
fill
className="object-contain p-1 group-hover:scale-110 transition-transform duration-300"
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors duration-200 font-medium">
{name.replace(/-/g, " ")}
</span>
{matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>}
</Link>
</MagicCard>
)
}
interface IconsGridProps {
filteredIcons: { name: string; data: Icon }[]
matchedAliases: Record<string, string>
}
function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2">
{filteredIcons.slice(0, 120).map(({ name, data }) => (
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name] || null} />
))}
</div>
{filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>}
</>
)
}