From 63349f74904b67a8fd23a769f3a4571039ab3820 Mon Sep 17 00:00:00 2001 From: Bjorn Lammers Date: Thu, 17 Apr 2025 07:21:19 +0200 Subject: [PATCH] feat(website): enhance website --- web/package.json | 2 + web/pnpm-lock.yaml | 16 + web/src/app/error.tsx | 51 ++ web/src/app/icons/components/icon-search.tsx | 520 ++++++++++++++++--- web/src/app/not-found.tsx | 36 +- web/src/components/client-header.tsx | 161 ------ web/src/components/command-menu.tsx | 186 ++++--- web/src/components/footer.tsx | 129 ++++- web/src/components/header-wrapper.tsx | 4 +- web/src/components/header.tsx | 92 ++-- web/src/components/hero.tsx | 8 +- web/src/components/icon-details.tsx | 142 ++++- web/src/components/icon-submission-form.tsx | 48 +- web/src/components/license-notice.tsx | 2 +- web/src/components/recently-added-icons.tsx | 50 +- web/src/hooks/use-media-query.ts | 25 + web/src/lib/api.ts | 160 ++++-- web/src/lib/utils.ts | 119 +++++ 18 files changed, 1264 insertions(+), 487 deletions(-) create mode 100644 web/src/app/error.tsx delete mode 100644 web/src/components/client-header.tsx create mode 100644 web/src/hooks/use-media-query.ts 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. +

+
+ + +
+ {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 */} + + + + + + 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 */} + + + + + + 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") && ( + + )} +
+ + {/* Active filter badges */} + {selectedCategories.length > 0 && ( +
+ Filters: +
+ {selectedCategories.map((category) => ( + + {category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())} + + + ))} +
+ + +
+ )} + +
{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."} +

+
) : ( -
- {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 icons

-
-
-
-
- - {/* Mobile search button */} -
- -
- -
- {isLoaded && } - - - - - - - -

GitHub

-
-
-
-
- - - {/* Mobile menu */} -
- - - - - -
-
-

Dashboard Icons

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