diff --git a/web/package.json b/web/package.json
index 5f7cc94b..7b5eda0d 100644
--- a/web/package.json
+++ b/web/package.json
@@ -39,6 +39,7 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-virtual": "^3.13.6",
+ "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -67,6 +68,7 @@
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@tailwindcss/postcss": "^4.1.3",
+ "@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.14.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.2",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index 0e56acf6..6eb08419 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -92,6 +92,9 @@ importers:
'@tanstack/react-virtual':
specifier: ^3.13.6
version: 3.13.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ canvas-confetti:
+ specifier: ^1.9.3
+ version: 1.9.3
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -171,6 +174,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.1.3
version: 4.1.3
+ '@types/canvas-confetti':
+ specifier: ^1.9.0
+ version: 1.9.0
'@types/node':
specifier: ^22.14.0
version: 22.14.0
@@ -1122,6 +1128,9 @@ packages:
'@tanstack/virtual-core@3.13.6':
resolution: {integrity: sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==}
+ '@types/canvas-confetti@1.9.0':
+ resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
+
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
@@ -1181,6 +1190,9 @@ packages:
caniuse-lite@1.0.30001713:
resolution: {integrity: sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==}
+ canvas-confetti@1.9.3:
+ resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==}
+
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -2673,6 +2685,8 @@ snapshots:
'@tanstack/virtual-core@3.13.6': {}
+ '@types/canvas-confetti@1.9.0': {}
+
'@types/d3-array@3.2.1': {}
'@types/d3-color@3.1.3': {}
@@ -2734,6 +2748,8 @@ snapshots:
caniuse-lite@1.0.30001713: {}
+ canvas-confetti@1.9.3: {}
+
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
diff --git a/web/src/app/error.tsx b/web/src/app/error.tsx
new file mode 100644
index 00000000..3b332632
--- /dev/null
+++ b/web/src/app/error.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import { Button } from "@/components/ui/button"
+import { AlertTriangle, ArrowLeft, RefreshCcw } from "lucide-react"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { useEffect } from "react"
+
+export default function ErrorPage({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string }
+ reset: () => void
+}) {
+ const router = useRouter()
+
+ useEffect(() => {
+ // Log the error to an error reporting service
+ console.error("Application error:", error)
+ }, [error])
+
+ const handleGoBack = () => {
+ router.back()
+ }
+
+ return (
+
+
+
+
Something went wrong
+
+ An unexpected error occurred while loading this page. We've been notified and are looking into it.
+
+
+
reset()} className="cursor-pointer">
+
+ Try again
+
+
+
+ Go back
+
+
+ {error.digest &&
Error ID: {error.digest}
}
+
+
+ )
+}
diff --git a/web/src/app/icons/components/icon-search.tsx b/web/src/app/icons/components/icon-search.tsx
index c3722d67..07b79be6 100644
--- a/web/src/app/icons/components/icon-search.tsx
+++ b/web/src/app/icons/components/icon-search.tsx
@@ -1,67 +1,208 @@
"use client"
import { IconSubmissionContent } from "@/components/icon-submission-form"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
+import { Separator } from "@/components/ui/separator"
import { BASE_URL } from "@/constants"
-import type { IconSearchProps } from "@/types/icons"
+import { fuzzySearch } from "@/lib/utils"
+import { cn } from "@/lib/utils"
+import type { Icon, IconSearchProps, IconWithName } from "@/types/icons"
import { motion } from "framer-motion"
-import { Search } from "lucide-react"
+import { useInView } from "framer-motion"
+import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
+import { useTheme } from "next-themes"
import Image from "next/image"
import Link from "next/link"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
-import { useCallback, useEffect, useRef, useState } from "react"
-import { useTheme } from "next-themes"
-import { useInView } from "framer-motion"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+
+type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
export function IconSearch({ icons }: IconSearchProps) {
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 [selectedCategories, setSelectedCategories] = useState(initialCategories ?? [])
+ const [sortOption, setSortOption] = useState(initialSort)
const timeoutRef = useRef(null)
const { resolvedTheme } = useTheme()
- const [filteredIcons, setFilteredIcons] = useState(() => {
- if (!initialQuery?.trim()) return icons
- const q = initialQuery.toLowerCase()
- return icons.filter(({ name, data }) => {
- if (name.toLowerCase().includes(q)) return true
- if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
- if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
+ // Extract all unique categories
+ const allCategories = useMemo(() => {
+ const categories = new Set()
+ for (const icon of icons) {
+ for (const category of icon.data.categories) {
+ categories.add(category)
+ }
+ }
+ return Array.from(categories).sort()
+ }, [icons])
- return false
- })
- })
- const filterIcons = useCallback(
- (query: string) => {
- if (!query.trim()) {
- return icons
+ // Define filterIconsByQueryAndCategory BEFORE it's used in useState
+ const filterIconsByQueryAndCategories = useCallback((iconList: typeof icons, query: string, categories: string[], sort: SortOption) => {
+ let filtered = iconList
+
+ // Apply category filters (if any are selected)
+ if (categories.length > 0) {
+ filtered = filtered.filter(({ data }) =>
+ // Check if the icon has at least one of the selected categories
+ data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())),
+ )
+ }
+
+ // Create a scored version of icons for relevance and other sorting
+ let scoredIcons: { icon: (typeof filtered)[0]; score: number; matchedAlias?: string }[] = []
+
+ // Apply search query filter and calculate scores
+ if (query.trim()) {
+ scoredIcons = filtered.map((icon) => {
+ // Calculate scores for different fields
+ const nameScore = fuzzySearch(icon.name, query) * 1.5 // Give more weight to name matches
+
+ // Find matching alias if any
+ let matchedAlias: string | undefined = undefined
+ let maxAliasScore = 0
+
+ // Check each alias for a match
+ if (icon.data.aliases.length > 0) {
+ for (const alias of icon.data.aliases) {
+ const aliasScore = fuzzySearch(alias, query) * 1.4
+ if (aliasScore > maxAliasScore) {
+ maxAliasScore = aliasScore
+ matchedAlias = alias
+ }
+ }
+ }
+
+ // Get max score from categories
+ const categoryScore =
+ 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, maxAliasScore, categoryScore)
+
+ return {
+ icon,
+ score,
+ matchedAlias: score === maxAliasScore && maxAliasScore > 0 ? matchedAlias : undefined,
+ }
+ })
+
+ // Filter icons with a minimum score
+ scoredIcons = scoredIcons.filter((item) => item.score > 0.2) // Minimum threshold
+
+ // If we're using relevance sorting, apply it now
+ if (sort === "relevance") {
+ // Sort by score
+ scoredIcons.sort((a, b) => b.score - a.score)
+ return scoredIcons.map((item) => item.icon)
}
- const q = query.toLowerCase()
- return icons.filter(({ name, data }) => {
- if (name.toLowerCase().includes(q)) return true
- if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
- if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
+ // Otherwise, we'll use the filtered results for other sorting methods
+ filtered = scoredIcons.map((item) => item.icon)
+ }
- return false
+ // Apply sorting if not by relevance (which was already handled above)
+ 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()
})
- },
- [icons],
- )
+ }
+
+ // Default alphabetical sort if no query or sort option not recognized
+ return filtered.sort((a, b) => a.name.localeCompare(b.name))
+ }, [])
+
+ // Now use the function after it's been defined
+ const [filteredIcons, setFilteredIcons] = useState(() => {
+ return filterIconsByQueryAndCategories(icons, initialQuery ?? "", initialCategories ?? [], initialSort)
+ })
+
+ // Store matched aliases separately
+ const [matchedAliases, setMatchedAliases] = useState>({})
+
const updateResults = useCallback(
- (query: string) => {
- setFilteredIcons(filterIcons(query))
+ (query: string, categories: string[], sort: SortOption) => {
+ // Clear existing matched aliases
+ setMatchedAliases({})
+
+ // If we have a query, capture any matched aliases before filtering
+ if (query.trim()) {
+ const newMatches: Record = {}
+ const scoredResults = icons.map((icon) => {
+ // Check for alias matches
+ let bestAliasScore = 0
+ let bestAlias = ""
+ for (const alias of icon.data.aliases) {
+ const score = fuzzySearch(alias, query) * 1.4
+ if (score > bestAliasScore && score > 0.3) {
+ // Only consider strong matches
+ bestAliasScore = score
+ bestAlias = alias
+ }
+ }
+
+ // If the name match is weaker than alias match, store the alias
+ const nameScore = fuzzySearch(icon.name, query) * 1.5
+ if (bestAliasScore > nameScore && bestAliasScore > 0.3) {
+ newMatches[icon.name] = bestAlias
+ }
+
+ return { icon }
+ })
+
+ // Update the matched aliases
+ setMatchedAliases(newMatches)
+ }
+
+ // Continue with normal filtering
+ setFilteredIcons(filterIconsByQueryAndCategories(icons, query, categories, sort))
const params = new URLSearchParams()
if (query) params.set("q", query)
- const newUrl = query ? `${pathname}?${params.toString()}` : pathname
+ // Clear existing category params and add new ones
+ for (const category of categories) {
+ params.append("category", category)
+ }
+ // Add sort parameter if not default
+ if (sort !== "relevance" || initialSort !== "relevance") {
+ params.set("sort", sort)
+ }
+
+ const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname
router.push(newUrl, { scroll: false })
},
- [filterIcons, pathname, router],
+ [filterIconsByQueryAndCategories, icons, pathname, router, initialSort],
)
+
const handleSearch = useCallback(
(query: string) => {
setSearchQuery(query)
@@ -69,11 +210,45 @@ export function IconSearch({ icons }: IconSearchProps) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
- updateResults(query)
+ updateResults(query, selectedCategories, sortOption)
}, 100)
},
- [updateResults],
+ [updateResults, selectedCategories, sortOption],
)
+
+ const handleCategoryChange = useCallback(
+ (category: string) => {
+ let newCategories: string[]
+
+ if (selectedCategories.includes(category)) {
+ // Remove the category if it's already selected
+ newCategories = selectedCategories.filter((c) => c !== category)
+ } else {
+ // Add the category if it's not selected
+ newCategories = [...selectedCategories, category]
+ }
+
+ setSelectedCategories(newCategories)
+ updateResults(searchQuery, newCategories, sortOption)
+ },
+ [updateResults, searchQuery, selectedCategories, sortOption],
+ )
+
+ const handleSortChange = useCallback(
+ (sort: SortOption) => {
+ setSortOption(sort)
+ updateResults(searchQuery, selectedCategories, sort)
+ },
+ [updateResults, searchQuery, selectedCategories],
+ )
+
+ const clearFilters = useCallback(() => {
+ setSearchQuery("")
+ setSelectedCategories([])
+ setSortOption("relevance")
+ updateResults("", [], "relevance")
+ }, [updateResults])
+
useEffect(() => {
return () => {
if (timeoutRef.current) {
@@ -83,40 +258,216 @@ export function IconSearch({ icons }: IconSearchProps) {
}, [])
// Helper function to get the appropriate icon variant based on theme
- const getIconVariant = (name: string, data: any) => {
+ const getIconVariant = (name: string, data: Icon) => {
// Check if the icon has theme variants and use appropriate one
if (data.colors) {
// If in dark mode and a light variant exists, use the light variant
- if (resolvedTheme === 'dark' && data.colors.light) {
- return data.colors.light;
+ if (resolvedTheme === "dark" && data.colors.light) {
+ return data.colors.light
}
// If in light mode and a dark variant exists, use the dark variant
- else if (resolvedTheme === 'light' && data.colors.dark) {
- return data.colors.dark;
+ if (resolvedTheme === "light" && data.colors.dark) {
+ return data.colors.dark
}
}
// Fall back to the default name if no appropriate variant
- return name;
+ return name
}
if (!searchParams) return null
+ const getSortLabel = (sort: SortOption) => {
+ switch (sort) {
+ case "relevance":
+ return "Best match"
+ case "alphabetical-asc":
+ return "A to Z"
+ case "alphabetical-desc":
+ return "Z to A"
+ case "newest":
+ return "Newest first"
+ default:
+ return "Sort"
+ }
+ }
+
+ const getSortIcon = (sort: SortOption) => {
+ switch (sort) {
+ case "relevance":
+ return
+ case "alphabetical-asc":
+ return
+ case "alphabetical-desc":
+ return
+ case "newest":
+ return
+ default:
+ return
+ }
+ }
+
return (
<>
-
- handleSearch(e.target.value)}
- />
+ {/* Search input */}
+
+
+
+
+
handleSearch(e.target.value)}
+ />
+
+
+ {/* Filter and sort controls */}
+
+ {/* Filter dropdown */}
+
+
+
+
+ Filter
+ {selectedCategories.length > 0 && (
+
+ {selectedCategories.length}
+
+ )}
+
+
+
+ Categories
+
+
+
+ {allCategories.map((category) => (
+ handleCategoryChange(category)}
+ className="cursor-pointer capitalize"
+ >
+ {category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
+
+ ))}
+
+
+ {selectedCategories.length > 0 && (
+ <>
+
+ {
+ setSelectedCategories([])
+ updateResults(searchQuery, [], sortOption)
+ }}
+ className="cursor-pointer text-rose-500 focus:text-rose-500 focus:bg-rose-50 dark:focus:bg-rose-950/20"
+ >
+ Clear all filters
+
+ >
+ )}
+
+
+
+ {/* Sort dropdown */}
+
+
+
+ {getSortIcon(sortOption)}
+ {getSortLabel(sortOption)}
+
+
+
+ Sort By
+
+ handleSortChange(value as SortOption)}>
+
+
+ Best match
+
+
+ A to Z
+
+
+ Z to A
+
+
+
+ Newest first
+
+
+
+
+
+ {/* Clear all button */}
+ {(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
+
+
+ Clear all
+
+ )}
+
+
+ {/* Active filter badges */}
+ {selectedCategories.length > 0 && (
+
+
Filters:
+
+ {selectedCategories.map((category) => (
+
+ {category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
+ handleCategoryChange(category)}
+ >
+
+
+
+ ))}
+
+
+
{
+ setSelectedCategories([])
+ updateResults(searchQuery, [], sortOption)
+ }}
+ className="text-xs h-7 px-2 text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 cursor-pointer"
+ >
+ Clear all
+
+
+ )}
+
+
{filteredIcons.length === 0 ? (
@@ -127,32 +478,61 @@ export function IconSearch({ icons }: IconSearchProps) {
transition={{ duration: 0.5, delay: 0.2 }}
>
-
We don't have this one...yet!
+
We don't have this one...yet!
+
+ {searchQuery && selectedCategories.length > 0
+ ? `No icons found matching "${searchQuery}" with the selected filters.`
+ : searchQuery
+ ? `No icons found matching "${searchQuery}".`
+ : selectedCategories.length > 0
+ ? "No icons found with the selected filters."
+ : "No icons found matching your criteria."}
+
+
+ Clear all filters
+
) : (
-
- {filteredIcons.map(({ name, data }, index) => (
-
- ))}
-
+ <>
+
+
+ Found {filteredIcons.length} icon{filteredIcons.length !== 1 ? "s" : ""}.
+
+
+ {getSortIcon(sortOption)}
+ {getSortLabel(sortOption)}
+
+
+
+ {filteredIcons.map(({ name, data }) => (
+
+ ))}
+
+ >
)}
>
)
}
-function IconCard({ name, data, getIconVariant }: {
- name: string;
- data: any;
- getIconVariant: (name: string, data: any) => string;
+function IconCard({
+ name,
+ data,
+ getIconVariant,
+ matchedAlias,
+}: {
+ name: string
+ data: Icon
+ getIconVariant: (name: string, data: Icon) => string
+ matchedAlias?: string | null
}) {
- const ref = useRef(null);
+ const ref = useRef(null)
const isInView = useInView(ref, {
once: false,
amount: 0.2,
- margin: "100px 0px"
- });
+ margin: "100px 0px",
+ })
const variants = {
hidden: { opacity: 0, y: 20, scale: 0.95 },
@@ -160,15 +540,15 @@ function IconCard({ name, data, getIconVariant }: {
opacity: 1,
y: 0,
scale: 1,
- transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1.0] }
+ transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1.0] },
},
exit: {
opacity: 0,
y: -10,
scale: 0.98,
- transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1.0] }
- }
- };
+ transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1.0] },
+ },
+ }
return (
@@ -197,7 +577,11 @@ function IconCard({ name, data, getIconVariant }: {
{name.replace(/-/g, " ")}
+
+ {matchedAlias && (
+ Alias: {matchedAlias}
+ )}
- );
+ )
}
diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx
index 84b21b67..08f18a5a 100644
--- a/web/src/app/not-found.tsx
+++ b/web/src/app/not-found.tsx
@@ -1,5 +1,6 @@
+import { IconSubmissionContent } from "@/components/icon-submission-form"
import { Button } from "@/components/ui/button"
-import { AlertTriangle, ArrowLeft } from "lucide-react"
+import { AlertTriangle, ArrowLeft, PlusCircle } from "lucide-react"
import Link from "next/link"
export default function NotFound({
@@ -9,21 +10,38 @@ export default function NotFound({
}) {
return (
-
-
-
+
+
+
+
Icon not found
+
+ The icon you are looking for could not be found or there was an error loading it.
+
-
Icon not found
-
The icon you are looking for could not be found or there was an error loading it.
-
If you believe this is an error, please contact the maintainers of the repository.
-
-
+
+
+
+
+
+
Can't find what you're looking for?
+
+ Contribute to our icon collection by suggesting a new icon or improving an existing one.
+
+
+
+
+
+
+
)
diff --git a/web/src/components/client-header.tsx b/web/src/components/client-header.tsx
deleted file mode 100644
index 7831983d..00000000
--- a/web/src/components/client-header.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-"use client"
-
-import { IconSubmissionForm } from "@/components/icon-submission-form"
-import { ThemeSwitcher } from "@/components/theme-switcher"
-import { REPO_PATH } from "@/constants"
-import { getAllIcons } from "@/lib/api"
-import type { Icon } from "@/types/icons"
-import { motion } from "framer-motion"
-import { Github, Menu, Search } from "lucide-react"
-import Link from "next/link"
-import { useEffect, useState } from "react"
-import { CommandMenu } from "./command-menu"
-import { HeaderNav } from "./header-nav"
-import { Button } from "./ui/button"
-import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
-
-export function ClientHeader() {
- const [icons, setIcons] = useState
>({})
- const [isLoaded, setIsLoaded] = useState(false)
-
- useEffect(() => {
- async function loadIcons() {
- try {
- const iconsData = await getAllIcons()
- setIcons(iconsData)
- setIsLoaded(true)
- } catch (error) {
- console.error("Failed to load icons:", error)
- setIsLoaded(true)
- }
- }
-
- loadIcons()
- }, [])
-
- return (
-
-
-
-
-
Dashboard Icons
-
-
-
-
-
-
- {/* Desktop search button */}
-
-
-
-
-
-
- Search
-
- ⌘ K
-
-
-
-
- Search icons
-
-
-
-
-
- {/* Mobile search button */}
-
-
-
- Search icons
-
-
-
-
- {isLoaded &&
}
-
-
-
-
-
-
-
- GitHub
-
-
-
-
- GitHub
-
-
-
-
-
-
- {/* Mobile menu */}
-
-
-
-
-
- Toggle menu
-
-
-
-
-
-
Dashboard Icons
-
-
-
-
-
-
- {isLoaded && }
-
-
-
- GitHub Repository
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/web/src/components/command-menu.tsx b/web/src/components/command-menu.tsx
index f34f8cad..9ec4a760 100644
--- a/web/src/components/command-menu.tsx
+++ b/web/src/components/command-menu.tsx
@@ -1,103 +1,137 @@
"use client"
+import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { fuzzySearch } from "@/lib/utils"
+import { Icon } from "@/types/icons"
import { useRouter } from "next/navigation"
-import * as React from "react"
-
-import { Button } from "@/components/ui/button"
-import { CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
-import { ImageIcon, Search } from "lucide-react"
-import Link from "next/link"
+import { useCallback, useEffect, useState } from "react"
interface CommandMenuProps {
- icons: string[]
+ icons: {
+ name: string
+ data: {
+ categories: string[]
+ aliases: string[]
+ [key: string]: unknown
+ }
+ }[]
triggerButtonId?: string
- displayAsButton?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
}
-export function CommandMenu({ icons, triggerButtonId, displayAsButton = false }: CommandMenuProps) {
+export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalOnOpenChange }: CommandMenuProps) {
const router = useRouter()
- const [open, setOpen] = React.useState(false)
- const [mounted, setMounted] = React.useState(false)
- const [inputValue, setInputValue] = React.useState("")
- const getFilteredIcons = React.useCallback(() => {
- const query = inputValue.toLowerCase().trim()
- if (!query) return icons.slice(0, 75)
- return icons.filter((icon) => {
- const iconName = icon.toLowerCase()
- if (iconName.includes(query)) return true
- const parts = query.split(/\s+/)
- let lastIndex = -1
- return parts.every((part) => {
- const index = iconName.indexOf(part, lastIndex + 1)
- if (index === -1) return false
- lastIndex = index
- return true
- })
- })
- }, [icons, inputValue])
+ const [internalOpen, setInternalOpen] = useState(false)
+ const [query, setQuery] = useState("")
+ const isDesktop = useMediaQuery("(min-width: 768px)")
- const filteredIcons = getFilteredIcons()
+ // Use either external or internal state for controlling open state
+ const isOpen = externalOpen !== undefined ? externalOpen : internalOpen
- React.useEffect(() => {
- setMounted(true)
- }, [])
+ // Wrap setIsOpen in useCallback to fix dependency issue
+ const setIsOpen = useCallback(
+ (value: boolean) => {
+ if (externalOnOpenChange) {
+ externalOnOpenChange(value)
+ } else {
+ setInternalOpen(value)
+ }
+ },
+ [externalOnOpenChange],
+ )
- React.useEffect(() => {
- const down = (e: KeyboardEvent) => {
- if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
+ const filteredIcons = getFilteredIcons(icons, query)
+
+ 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()
- setOpen((open) => !open)
+ setIsOpen(!isOpen)
}
}
- document.addEventListener("keydown", down)
- return () => document.removeEventListener("keydown", down)
- }, [])
+ document.addEventListener("keydown", handleKeyDown)
+ return () => document.removeEventListener("keydown", handleKeyDown)
+ }, [isOpen, setIsOpen])
- // Effect to connect to external trigger button
- React.useEffect(() => {
- if (!triggerButtonId || !mounted) return
-
- const triggerButton = document.getElementById(triggerButtonId)
- if (!triggerButton) return
-
- const handleClick = () => {
- setOpen(true)
+ 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)
}
- triggerButton.addEventListener("click", handleClick)
- return () => triggerButton.removeEventListener("click", handleClick)
- }, [triggerButtonId, mounted])
+ // 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
- const handleInputChange = React.useCallback((value: string) => {
- setInputValue(value)
- }, [])
+ // 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
- const handleSelectIcon = React.useCallback(
- (iconName: string) => {
- router.push(`/icons/${iconName}`)
- setOpen(false)
- },
- [router],
- )
+ // 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
- if (!mounted) return null
+ // 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 (
-
-
-
- {filteredIcons.length === 0 && No results found. Try a different search term. }
- {filteredIcons.map((icon) => (
- handleSelectIcon(icon)} className="cursor-pointer">
-
-
-
-
- {icon.replace(/-/g, " ")}
-
-
- ))}
+
+
+
+ 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
+
+ return (
+ handleSelect(name)} className="flex items-center gap-2 cursor-pointer">
+
+
+ {name.substring(0, 2).toUpperCase()}
+
+
+ {name.replace(/-/g, " ")}
+ {matchedAlias && alias: {matchedAlias} }
+ {!matchedAlias && data.categories && data.categories.length > 0 && (
+
+ {data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
+
+ )}
+
+ )
+ })}
+
)
diff --git a/web/src/components/footer.tsx b/web/src/components/footer.tsx
index 2802fad7..d79a2306 100644
--- a/web/src/components/footer.tsx
+++ b/web/src/components/footer.tsx
@@ -4,8 +4,30 @@ import { REPO_PATH } from "@/constants"
import { motion } from "framer-motion"
import { ExternalLink, Github, Heart } from "lucide-react"
import Link from "next/link"
+import { useState } from "react"
+
+// Pre-define unique IDs for animations to avoid using array indices as keys
+const HOVER_HEART_IDS = [
+ "hover-heart-1",
+ "hover-heart-2",
+ "hover-heart-3",
+ "hover-heart-4",
+ "hover-heart-5",
+ "hover-heart-6",
+ "hover-heart-7",
+ "hover-heart-8",
+]
+const BURST_HEART_IDS = ["burst-heart-1", "burst-heart-2", "burst-heart-3", "burst-heart-4", "burst-heart-5"]
export function Footer() {
+ const [isHeartHovered, setIsHeartHovered] = useState(false)
+ const [isHeartFilled, setIsHeartFilled] = useState(false)
+
+ // Toggle heart fill state and add extra mini hearts on click
+ const handleHeartClick = () => {
+ setIsHeartFilled(!isHeartFilled)
+ }
+
return (
@@ -66,10 +88,109 @@ export function Footer() {
transition={{ duration: 0.5, delay: 0.2 }}
>
Community
-
- Made with by Homarr Labs and the open source
- community.
-
+
+ Made with{" "}
+
+ setIsHeartHovered(true)}
+ onMouseLeave={() => setIsHeartHovered(false)}
+ onClick={handleHeartClick}
+ whileTap={{ scale: 0.85 }}
+ >
+
+
+
+
+
+ {/* Easter egg mini hearts */}
+ {isHeartHovered && (
+ <>
+ {HOVER_HEART_IDS.map((id, i) => (
+
+
+
+ ))}
+
+ {/* Subtle particle glow */}
+
+ >
+ )}
+
+ {/* Heart fill animation extras */}
+ {isHeartFilled && (
+ <>
+ {/* Radiating circles on heart fill */}
+
+
+ {/* Extra burst of mini hearts when filled */}
+ {BURST_HEART_IDS.map((id, i) => (
+
+
+
+ ))}
+ >
+ )}
+
{" "}
+ by Homarr Labs and the open source community.
+
+ return
}
diff --git a/web/src/components/header.tsx b/web/src/components/header.tsx
index 9391f633..3ca1fea5 100644
--- a/web/src/components/header.tsx
+++ b/web/src/components/header.tsx
@@ -3,8 +3,8 @@
import { IconSubmissionForm } from "@/components/icon-submission-form"
import { ThemeSwitcher } from "@/components/theme-switcher"
import { REPO_PATH } from "@/constants"
-import { getAllIcons } from "@/lib/api"
-import type { Icon } from "@/types/icons"
+import { getIconsArray } from "@/lib/api"
+import type { IconWithName } from "@/types/icons"
import { motion } from "framer-motion"
import { Github, Menu, Search } from "lucide-react"
import Link from "next/link"
@@ -16,14 +16,15 @@ import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
export function Header() {
- const [icons, setIcons] = useState>({})
+ const [iconsData, setIconsData] = useState([])
const [isLoaded, setIsLoaded] = useState(false)
+ const [commandMenuOpen, setCommandMenuOpen] = useState(false)
useEffect(() => {
async function loadIcons() {
try {
- const iconsData = await getAllIcons()
- setIcons(iconsData)
+ const icons = await getIconsArray()
+ setIconsData(icons)
setIsLoaded(true)
} catch (error) {
console.error("Failed to load icons:", error)
@@ -34,6 +35,11 @@ export function Header() {
loadIcons()
}, [])
+ // Function to open the command menu
+ const openCommandMenu = () => {
+ setCommandMenuOpen(true)
+ }
+
return (
-
-
Dashboard Icons
-
+
+
Dashboard Icons
@@ -54,27 +59,18 @@ export function Header() {
{/* Desktop search button */}
-
-
-
-
-
- Search
-
- ⌘ K
-
-
-
-
- Search icons
-
-
-
+
+
+ Find icons
+
+ ⌘ K
+
+
{/* Mobile search button */}
@@ -82,16 +78,15 @@ export function Header() {
-
- Search icons
+
+ Find icons
- {isLoaded &&
}
@@ -99,17 +94,17 @@ export function Header() {
-
- GitHub
+
+ View on GitHub
- GitHub
+ View on GitHub
@@ -123,9 +118,9 @@ export function Header() {
-
+
Toggle menu
@@ -139,14 +134,22 @@ export function Header() {
- {isLoaded && }
+
+
+ Find and filter icons
+
-
+
GitHub Repository
@@ -157,6 +160,9 @@ export function Header() {
+
+ {/* Single instance of CommandMenu */}
+ {isLoaded &&
}
)
}
diff --git a/web/src/components/hero.tsx b/web/src/components/hero.tsx
index b337d59c..8af7fa37 100644
--- a/web/src/components/hero.tsx
+++ b/web/src/components/hero.tsx
@@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"
import { motion, useAnimation, useInView } from "framer-motion"
import { Circle, Github, Heart, Search, Sparkles } from "lucide-react"
import Link from "next/link"
-import { useEffect, useState, useRef } from "react"
+import { useEffect, useRef, useState } from "react"
interface IconCardProps {
name: string
@@ -72,7 +72,7 @@ function ElegantShape({
delay,
ease: [0.23, 0.86, 0.39, 0.96],
opacity: { duration: 1.2 },
- }
+ },
})
}
}, [controls, delay, isInView, rotate])
@@ -302,7 +302,7 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
setSearchQuery(e.target.value)}
@@ -318,7 +318,7 @@ export function HeroSection({ totalIcons }: { totalIcons: number }) {
- Browse all icons
+ Explore all icons
>({})
+ // Launch confetti from the pointer position
+ const launchConfetti = useCallback((originX?: number, originY?: number) => {
+ const defaults = {
+ startVelocity: 30,
+ spread: 360,
+ ticks: 50,
+ zIndex: 0,
+ disableForReducedMotion: true,
+ colors: ["#ff0a54", "#ff477e", "#ff7096", "#ff85a1", "#fbb1bd", "#f9bec7"],
+ }
+
+ // If we have origin coordinates, use them
+ if (originX !== undefined && originY !== undefined) {
+ confetti({
+ ...defaults,
+ particleCount: 100,
+ origin: { x: originX / window.innerWidth, y: originY / window.innerHeight },
+ })
+ } else {
+ // Default to center of screen
+ confetti({
+ ...defaults,
+ particleCount: 100,
+ origin: { x: 0.5, y: 0.5 },
+ })
+ }
+ }, [])
+
// Helper function to get the appropriate icon variant based on theme
const getIconVariant = (iconName: string) => {
// Check if the icon has theme variants
if (iconColorVariants) {
// If in dark mode and a light variant exists, use the light variant
- if (resolvedTheme === 'dark' && iconColorVariants.light) {
- return iconColorVariants.light;
+ if (resolvedTheme === "dark" && iconColorVariants.light) {
+ return iconColorVariants.light
}
// If in light mode and a dark variant exists, use the dark variant
- else if (resolvedTheme === 'light' && iconColorVariants.dark) {
- return iconColorVariants.dark;
+ if (resolvedTheme === "light" && iconColorVariants.dark) {
+ return iconColorVariants.dark
}
}
// Fall back to the default name if no appropriate variant
- return iconName;
+ return iconName
}
- const handleCopy = (url: string, variantKey: string) => {
+ const handleCopy = (url: string, variantKey: string, event?: React.MouseEvent) => {
navigator.clipboard.writeText(url)
setCopiedVariants((prev) => ({
...prev,
@@ -74,11 +103,57 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
}))
}, 2000)
+ // Launch confetti from click position or center of screen
+ if (event) {
+ launchConfetti(event.clientX, event.clientY)
+ } else {
+ launchConfetti()
+ }
+
toast.success("URL copied", {
- description: "The icon URL has been copied to your clipboard",
+ description: "The icon URL has been copied to your clipboard. Ready to use!",
})
}
+ const handleDownload = async (event: React.MouseEvent, url: string, filename: string) => {
+ event.preventDefault()
+
+ // Launch confetti from download button position
+ launchConfetti(event.clientX, event.clientY)
+
+ try {
+ // Show loading toast
+ toast.loading("Preparing download...")
+
+ // Fetch the file first as a blob
+ const response = await fetch(url)
+ const blob = await response.blob()
+
+ // Create a blob URL and use it for download
+ const blobUrl = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = blobUrl
+ link.download = filename
+ document.body.appendChild(link)
+ link.click()
+
+ // Clean up
+ document.body.removeChild(link)
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
+
+ toast.dismiss()
+ toast.success("Download started", {
+ description: "Your icon file is being downloaded and will be saved to your device.",
+ })
+ } catch (error) {
+ console.error("Download error:", error)
+ toast.dismiss()
+ toast.error("Download failed", {
+ description: "There was an error downloading the file. Please try again.",
+ })
+ }
+ }
+
const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => {
const variantName = theme && iconColorVariants?.[theme] ? iconColorVariants[theme] : iconName
const url = `${BASE_URL}/${format}/${variantName}.${format}`
@@ -95,7 +170,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
- onClick={() => handleCopy(url, variantKey)}
+ onClick={(e) => handleCopy(url, variantKey, e)}
>
@@ -123,7 +198,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
- Click to copy URL to clipboard
+ Click to copy direct URL to clipboard
@@ -132,14 +207,17 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
-
-
-
-
+ handleDownload(e, url, `${iconName}.${format}`)}
+ >
+
- Download icon
+ Download icon file
@@ -149,13 +227,13 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
- onClick={() => handleCopy(url, `btn-${variantKey}`)}
+ onClick={(e) => handleCopy(url, `btn-${variantKey}`, e)}
>
{copiedVariants[`btn-${variantKey}`] ?
:
}
- Copy URL to clipboard
+ Copy direct URL to clipboard
@@ -235,9 +313,16 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
Categories
{iconData.categories.map((category) => (
-
- {category}
-
+
+ {category
+ .split("-")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ")}
+
))}
@@ -246,13 +331,18 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{iconData.aliases && iconData.aliases.length > 0 && (
Aliases
-
+
{iconData.aliases.map((alias) => (
-
+
{alias}
))}
+
These aliases can be used to find this icon in search results.
)}
@@ -350,11 +440,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
Source
-
+
View on GitHub
diff --git a/web/src/components/icon-submission-form.tsx b/web/src/components/icon-submission-form.tsx
index 54269829..67f6a4e7 100644
--- a/web/src/components/icon-submission-form.tsx
+++ b/web/src/components/icon-submission-form.tsx
@@ -2,7 +2,6 @@
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { REPO_PATH } from "@/constants"
import { DialogDescription } from "@radix-ui/react-dialog"
import { ExternalLink, PlusCircle } from "lucide-react"
@@ -13,31 +12,31 @@ export const ISSUE_TEMPLATES = [
{
id: "add_monochrome_icon",
name: "Add light & dark icon",
- description: "Use this template to add a new icon to the project. Monochrome icons need both light and dark versions.",
+ description: "Submit a new icon with both light and dark versions for optimal theme compatibility.",
url: `${REPO_PATH}/issues/new?template=add_monochrome_icon.yml`,
},
{
id: "add_normal_icon",
name: "Add normal icon",
- description: "Use this template to add a new icon to the project. Normal icons work for both light and dark themes.",
+ description: "Submit a new icon that works well across both light and dark themes.",
url: `${REPO_PATH}/issues/new?template=add_normal_icon.yml`,
},
{
id: "update_monochrome_icon",
name: "Update light & dark icon",
- description: "Use this template to update an existing icon. Monochrome icons need both light and dark versions.",
+ description: "Improve an existing icon by updating both light and dark versions.",
url: `${REPO_PATH}/issues/new?template=update_monochrome_icon.yml`,
},
{
id: "update_normal_icon",
name: "Update normal icon",
- description: "Use this template to update an existing icon. Normal icons work for both light and dark themes.",
+ description: "Improve an existing icon that works across both light and dark themes.",
url: `${REPO_PATH}/issues/new?template=update_normal_icon.yml`,
},
{
id: "blank_issue",
name: "Something else",
- description: "You'd like to do something else? Use this template to create a new issue.",
+ description: "Create a custom issue for other suggestions, bug reports, or improvements.",
url: `${REPO_PATH}/issues/new?template=BLANK_ISSUE`,
},
]
@@ -46,15 +45,15 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
{ISSUE_TEMPLATES.map((template) => (
-
+
- {template.name}
-
+ {template.name}
+
{template.description}
@@ -69,27 +68,18 @@ export function IconSubmissionForm() {
return (
-
-
-
-
-
- Suggest new icon
-
-
-
-
- Suggest a new icon
-
-
-
+
+
+ Contribute new icon
+
+
- Suggest a new icon
- You can suggest a new icon by creating an issue on GitHub using one of the templates below.
+ Contribute a new icon
+ Choose a template below to suggest a new icon or improve an existing one.
setOpen(false)} />
diff --git a/web/src/components/license-notice.tsx b/web/src/components/license-notice.tsx
index ad669d64..1f809c8d 100644
--- a/web/src/components/license-notice.tsx
+++ b/web/src/components/license-notice.tsx
@@ -42,7 +42,7 @@ export function LicenseNotice() {
identification purposes only and do not imply endorsement.
- Read the{" "}
+ View our{" "}
{
+ const getIconVariant = (name: string, data: Icon) => {
// Check if the icon has theme variants and use appropriate one
if (data.colors) {
// If in dark mode and a light variant exists, use the light variant
- if (resolvedTheme === 'dark' && data.colors.light) {
- return data.colors.light;
+ if (resolvedTheme === "dark" && data.colors.light) {
+ return data.colors.light
}
// If in light mode and a dark variant exists, use the dark variant
- else if (resolvedTheme === 'light' && data.colors.dark) {
- return data.colors.dark;
+ if (resolvedTheme === "light" && data.colors.dark) {
+ return data.colors.dark
}
}
// Fall back to the default name if no appropriate variant
- return name;
+ return name
}
return (
@@ -74,18 +74,18 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
))}
-
- View all icons
+ View complete collection
@@ -95,17 +95,21 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
}
// Extracted component for better animation handling
-function RecentIconCard({ name, data, getIconVariant }: {
- name: string;
- data: any;
- getIconVariant: (name: string, data: any) => string;
+function RecentIconCard({
+ name,
+ data,
+ getIconVariant,
+}: {
+ name: string
+ data: Icon
+ getIconVariant: (name: string, data: Icon) => string
}) {
- const ref = useRef(null);
+ const ref = useRef(null)
const isInView = useInView(ref, {
once: false,
amount: 0.2,
- margin: "100px 0px"
- });
+ margin: "100px 0px",
+ })
const variants = {
hidden: { opacity: 0, y: 20, scale: 0.95 },
@@ -113,15 +117,15 @@ function RecentIconCard({ name, data, getIconVariant }: {
opacity: 1,
y: 0,
scale: 1,
- transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1.0] }
+ transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1.0] },
},
exit: {
opacity: 0,
y: -10,
scale: 0.98,
- transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1.0] }
- }
- };
+ transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1.0] },
+ },
+ }
return (
- );
+ )
}
diff --git a/web/src/hooks/use-media-query.ts b/web/src/hooks/use-media-query.ts
new file mode 100644
index 00000000..1107ab8a
--- /dev/null
+++ b/web/src/hooks/use-media-query.ts
@@ -0,0 +1,25 @@
+"use client"
+
+import { useEffect, useState } from "react"
+
+export function useMediaQuery(query: string): boolean {
+ const [matches, setMatches] = useState(false)
+
+ useEffect(() => {
+ const media = window.matchMedia(query)
+
+ // Initial check
+ if (media.matches !== matches) {
+ setMatches(media.matches)
+ }
+
+ // Setup listener for changes
+ const listener = () => setMatches(media.matches)
+ media.addEventListener("change", listener)
+
+ // Cleanup
+ return () => media.removeEventListener("change", listener)
+ }, [query, matches])
+
+ return matches
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 8b70159f..df0e254d 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -1,50 +1,94 @@
import { METADATA_URL } from "@/constants"
import type { IconFile, IconWithName } from "@/types/icons"
+
+/**
+ * Custom error class for API errors
+ */
+export class ApiError extends Error {
+ status: number
+
+ constructor(message: string, status = 500) {
+ super(message)
+ this.name = "ApiError"
+ this.status = status
+ }
+}
+
/**
* Fetches all icon data from the metadata.json file
*/
-
export async function getAllIcons(): Promise {
- const file = await fetch(METADATA_URL)
- return (await file.json()) as IconFile
+ try {
+ const response = await fetch(METADATA_URL)
+
+ if (!response.ok) {
+ throw new ApiError(`Failed to fetch icons: ${response.statusText}`, response.status)
+ }
+
+ return (await response.json()) as IconFile
+ } catch (error) {
+ if (error instanceof ApiError) {
+ throw error
+ }
+ console.error("Error fetching icons:", error)
+ throw new ApiError("Failed to fetch icons data. Please try again later.")
+ }
}
/**
* Gets a list of all icon names.
*/
export const getIconNames = async (): Promise => {
- const iconsData = await getAllIcons()
- return Object.keys(iconsData)
+ try {
+ const iconsData = await getAllIcons()
+ return Object.keys(iconsData)
+ } catch (error) {
+ console.error("Error getting icon names:", error)
+ throw error
+ }
}
/**
* Converts icon data to an array format for easier rendering
*/
export async function getIconsArray(): Promise {
- const iconsData = await getAllIcons()
+ try {
+ const iconsData = await getAllIcons()
- return Object.entries(iconsData)
- .map(([name, data]) => ({
- name,
- data,
- }))
- .sort((a, b) => a.name.localeCompare(b.name))
+ return Object.entries(iconsData)
+ .map(([name, data]) => ({
+ name,
+ data,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+ } catch (error) {
+ console.error("Error getting icons array:", error)
+ throw error
+ }
}
/**
* Fetches data for a specific icon
*/
export async function getIconData(iconName: string): Promise {
- const iconsData = await getAllIcons()
- const iconData = iconsData[iconName]
+ try {
+ const iconsData = await getAllIcons()
+ const iconData = iconsData[iconName]
- if (!iconData) {
- return null
- }
+ if (!iconData) {
+ throw new ApiError(`Icon '${iconName}' not found`, 404)
+ }
- return {
- name: iconName,
- data: iconData,
+ return {
+ name: iconName,
+ data: iconData,
+ }
+ } catch (error) {
+ if (error instanceof ApiError && error.status === 404) {
+ return null
+ }
+ console.error("Error getting icon data:", error)
+ throw error
}
}
@@ -52,24 +96,57 @@ export async function getIconData(iconName: string): Promise {
- const icons = await getIconsArray()
+ try {
+ const icons = await getIconsArray()
- return icons
- .sort((a, b) => {
- // Sort by timestamp in descending order (newest first)
- return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime()
- })
- .slice(0, limit)
+ return icons
+ .sort((a, b) => {
+ // Sort by timestamp in descending order (newest first)
+ return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime()
+ })
+ .slice(0, limit)
+ } catch (error) {
+ console.error("Error getting recently added icons:", error)
+ throw error
+ }
}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
index 5e1abdf4..9def50f7 100644
--- a/web/src/lib/utils.ts
+++ b/web/src/lib/utils.ts
@@ -4,3 +4,122 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+
+/**
+ * Calculate Levenshtein distance between two strings
+ */
+export function levenshteinDistance(a: string, b: string): number {
+ const matrix: number[][] = []
+
+ // Initialize the matrix
+ for (let i = 0; i <= b.length; i++) {
+ matrix[i] = [i]
+ }
+ for (let j = 0; j <= a.length; j++) {
+ matrix[0][j] = j
+ }
+
+ // Fill the matrix
+ for (let i = 1; i <= b.length; i++) {
+ for (let j = 1; j <= a.length; j++) {
+ const cost = a[j - 1] === b[i - 1] ? 0 : 1
+ matrix[i][j] = Math.min(
+ matrix[i - 1][j] + 1, // deletion
+ matrix[i][j - 1] + 1, // insertion
+ matrix[i - 1][j - 1] + cost, // substitution
+ )
+ }
+ }
+
+ return matrix[b.length][a.length]
+}
+
+/**
+ * Calculate similarity score between two strings (0-1)
+ * Higher score means more similar
+ */
+export function calculateStringSimilarity(str1: string, str2: string): number {
+ if (!str1.length || !str2.length) return 0
+ if (str1 === str2) return 1
+
+ const distance = levenshteinDistance(str1.toLowerCase(), str2.toLowerCase())
+ const maxLength = Math.max(str1.length, str2.length)
+ return 1 - distance / maxLength
+}
+
+/**
+ * Check if string contains all characters from query in order
+ * Returns match score (0 if no match)
+ */
+export function containsCharsInOrder(str: string, query: string): number {
+ if (!query) return 1
+ if (!str) return 0
+
+ const normalizedStr = str.toLowerCase()
+ const normalizedQuery = query.toLowerCase()
+
+ let strIndex = 0
+ let queryIndex = 0
+
+ while (strIndex < normalizedStr.length && queryIndex < normalizedQuery.length) {
+ if (normalizedStr[strIndex] === normalizedQuery[queryIndex]) {
+ queryIndex++
+ }
+ strIndex++
+ }
+
+ // If we matched all characters in the query
+ if (queryIndex === normalizedQuery.length) {
+ // Calculate a score based on closeness of matches
+ // Higher score if characters are close together
+ const matchRatio = normalizedStr.length / (strIndex + 1)
+ return matchRatio
+ }
+
+ return 0
+}
+
+/**
+ * Advanced fuzzy search with multiple scoring methods
+ * Returns a score from 0-1, where 1 is a perfect match
+ */
+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
+
+ // Check for character sequence matches
+ const sequenceScore = containsCharsInOrder(normalizedText, normalizedQuery)
+
+ // Calculate string similarity
+ const similarityScore = calculateStringSimilarity(normalizedText, normalizedQuery)
+
+ // Word-by-word matching for multi-word queries
+ 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.includes(queryWord) ||
+ calculateStringSimilarity(textWord, queryWord) > 0.7 ||
+ containsCharsInOrder(textWord, queryWord) > 0
+ ) {
+ wordMatchCount++
+ break
+ }
+ }
+ }
+
+ 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)
+}