Merge branch 'main' into feat/wordmark-icons

This commit is contained in:
Thomas Camlong
2025-04-29 17:09:20 +02:00
269 changed files with 26106 additions and 25211 deletions

View File

@@ -7,7 +7,7 @@ import { Suspense, useEffect } from "react"
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (process.env.DISABLE_POSTHOG === "true") return
if (process.env.NEXT_PUBLIC_DISABLE_POSTHOG === "true") return
// biome-ignore lint/style/noNonNullAssertion: The NEXT_PUBLIC_POSTHOG_KEY environment variable is guaranteed to be set in production.
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
ui_host: "https://eu.posthog.com",

View File

@@ -1,21 +1,16 @@
"use client"
import { Badge } from "@/components/ui/badge"
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 { filterAndSortIcons, formatIconName, fuzzySearch } from "@/lib/utils"
import type { IconWithName } from "@/types/icons"
import { Info, Search as SearchIcon, Tag } from "lucide-react"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
interface CommandMenuProps {
icons: {
name: string
data: {
categories: string[]
aliases: string[]
[key: string]: unknown
}
}[]
icons: IconWithName[]
triggerButtonId?: string
open?: boolean
onOpenChange?: (open: boolean) => void
@@ -42,7 +37,9 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
[externalOnOpenChange],
)
const filteredIcons = getFilteredIcons(icons, query)
const filteredIcons = useMemo(() => filterAndSortIcons({ icons, query, limit: 20 }), [icons, query])
const totalIcons = icons.length
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -59,80 +56,93 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
return () => document.removeEventListener("keydown", handleKeyDown)
}, [isOpen, setIsOpen])
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)
}
// 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
// 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
// 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
// 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 (
<CommandDialog open={isOpen} onOpenChange={setIsOpen}>
<CommandInput placeholder="Search for icons by name, category, or purpose..." value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>No matching icons found. Try a different search term or browse all icons.</CommandEmpty>
<CommandGroup heading="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
const handleBrowseAll = () => {
setIsOpen(false)
router.push("/icons")
}
return (
<CommandItem key={name} value={name} onSelect={() => handleSelect(name)} className="flex items-center gap-2 cursor-pointer">
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-5 w-5 bg-rose-100 dark:bg-rose-900/30 rounded-md flex items-center justify-center">
<span className="text-[10px] font-medium text-rose-800 dark:text-rose-300">{name.substring(0, 2).toUpperCase()}</span>
return (
<CommandDialog open={isOpen} onOpenChange={setIsOpen} contentClassName="bg-background/90 backdrop-blur-sm border border-border/60">
<CommandInput
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-[300px]">
{/* Icon Results */}
<CommandGroup heading="Icons">
{filteredIcons.length > 0 &&
filteredIcons.map(({ name, data }) => {
const formatedIconName = formatIconName(name)
const hasCategories = data.categories && data.categories.length > 0
return (
<CommandItem
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="flex items-center gap-2 cursor-pointer py-1.5"
>
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-full w-full bg-primary/10 dark:bg-primary/20 rounded-md flex items-center justify-center">
<span className="text-[9px] font-medium text-primary dark:text-primary-foreground">
{name.substring(0, 2).toUpperCase()}
</span>
</div>
</div>
</div>
<span className="flex-grow capitalize">{name.replace(/-/g, " ")}</span>
{matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>}
{!matchedAlias && data.categories && data.categories.length > 0 && (
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
)}
</CommandItem>
)
})}
<span className="flex-grow capitalize font-medium text-sm">{formatedIconName}</span>
{hasCategories && (
<div className="flex gap-1 items-center flex-shrink-0 overflow-hidden max-w-[40%]">
{/* First category */}
<Badge
key={data.categories[0]}
variant="secondary"
className="text-xs font-normal inline-flex items-center gap-1 whitespace-nowrap max-w-[120px] overflow-hidden"
>
<Tag size={8} className="mr-1 flex-shrink-0" />
<span className="truncate">{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}</span>
</Badge>
{/* "+N" badge if more than one category */}
{data.categories.length > 1 && (
<Badge variant="outline" className="text-xs flex-shrink-0">
+{data.categories.length - 1}
</Badge>
)}
</div>
)}
</CommandItem>
)
})}
</CommandGroup>
<CommandEmpty>
{/* Minimal empty state */}
<div className="py-2 px-2 text-center text-xs text-muted-foreground flex items-center justify-center gap-1.5">
<Info className="h-3.5 w-3.5 text-destructive" /> {/* Smaller red icon */}
<span>No matching icons found.</span>
</div>
</CommandEmpty>
</CommandList>
{/* Separator and Browse section - Styled div outside CommandList */}
<div className="border-t border-border/40 pt-1 mt-1 px-1 pb-1">
<button
type="button"
className="flex items-center gap-2 cursor-pointer rounded-sm px-2 py-1 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground w-full"
onClick={handleBrowseAll}
>
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-full w-full bg-primary/80 dark:bg-primary/40 rounded-md flex items-center justify-center">
<SearchIcon className="text-primary-foreground dark:text-primary-200 w-3.5 h-3.5" />
</div>
</div>
<span className="flex-grow text-sm">Browse all icons {totalIcons} available</span>
</button>
</div>
</CommandDialog>
)
}

View File

@@ -1,43 +1,19 @@
"use client"
import { REPO_PATH } from "@/constants"
import { motion } from "framer-motion"
import { ExternalLink, Heart } from "lucide-react"
import { ExternalLink } 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"]
import { HeartEasterEgg } from "./heart"
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 (
<footer className="border-t py-4 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-rose-500/[0.03] via-transparent to-rose-500/[0.03]" />
<div className="absolute inset-0 bg-background bg-gradient-to-r from-primary/[0.03] via-transparent to-primary/[0.03]" />
<div className="container mx-auto mb-2 px-4 md:px-6 relative z-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
<div className="flex flex-col gap-3">
<h3 className="font-bold text-lg text-foreground/90">Dashboard Icons</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
A collection of curated icons for services, applications and tools, designed specifically for dashboards and app directories.
Collection of icons for applications, services, and tools - designed for dashboards and app directories.
</p>
</div>
@@ -53,117 +29,9 @@ export function Footer() {
</div>
</div>
<motion.div
className="flex flex-col gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="flex flex-col gap-3">
<h3 className="font-bold text-lg text-foreground/90">Community</h3>
<div className="text-sm flex flex-wrap items-center gap-1.5 leading-relaxed">
Made with{" "}
<div className="relative inline-block">
<motion.div
className="cursor-pointer"
onMouseEnter={() => setIsHeartHovered(true)}
onMouseLeave={() => setIsHeartHovered(false)}
onClick={handleHeartClick}
whileTap={{ scale: 0.85 }}
>
<motion.div
animate={{
scale: isHeartFilled ? [1, 1.3, 1] : 1,
}}
transition={{
duration: isHeartFilled ? 0.4 : 0,
ease: "easeInOut",
}}
>
<Heart
className="h-3.5 w-3.5 flex-shrink-0 hover:scale-125 transition-all duration-200"
fill={isHeartFilled ? "#f43f5e" : "none"}
strokeWidth={isHeartFilled ? 1.5 : 2}
/>
</motion.div>
</motion.div>
{/* Easter egg mini hearts */}
{isHeartHovered && (
<>
{HOVER_HEART_IDS.map((id, i) => (
<motion.div
key={id}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, (i % 2 === 0 ? 1 : -1) * Math.random() * 20],
y: [0, -Math.random() * 30],
}}
transition={{
duration: 0.8 + Math.random() * 0.5,
ease: "easeOut",
delay: Math.random() * 0.2,
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className={`h-2 w-2 ${i < 3 ? "text-rose-300" : i < 6 ? "text-rose-400" : ""}`} />
</motion.div>
))}
{/* Subtle particle glow */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 3],
opacity: [0, 0.3, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-rose-500/20 pointer-events-none"
/>
</>
)}
{/* Heart fill animation extras */}
{isHeartFilled && (
<>
{/* Radiating circles on heart fill */}
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: [0.5, 2.5],
opacity: [0.5, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 w-3 h-3 rounded-full bg-rose-500/30 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
/>
{/* Extra burst of mini hearts when filled */}
{BURST_HEART_IDS.map((id, i) => (
<motion.div
key={id}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, Math.cos((i * Math.PI) / 2.5) * 25],
y: [0, Math.sin((i * Math.PI) / 2.5) * 25],
}}
transition={{
duration: 0.6,
ease: "easeOut",
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className="h-2 w-2 " fill="#f43f5e" />
</motion.div>
))}
</>
)}
</div>{" "}
by Homarr Labs and the open source community.
</div>
<HeartEasterEgg />
<Link
href={REPO_PATH}
target="_blank"
@@ -173,7 +41,7 @@ export function Footer() {
Contribute to this project
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
</Link>
</motion.div>
</div>
</div>
</div>
</footer>

View File

@@ -5,8 +5,7 @@ import { ThemeSwitcher } from "@/components/theme-switcher"
import { REPO_PATH } from "@/constants"
import { getIconsArray } from "@/lib/api"
import type { IconWithName } from "@/types/icons"
import { motion } from "framer-motion"
import { Github, Search } from "lucide-react"
import { Github, PlusCircle, Search } from "lucide-react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { CommandMenu } from "./command-menu"
@@ -40,12 +39,7 @@ export function Header() {
}
return (
<motion.header
className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<header className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50">
<div className="px-4 md:px-12 flex items-center justify-between h-16 md:h-18">
<div className="flex items-center gap-2 md:gap-6">
<Link href="/" className="text-lg md:text-xl font-bold group hidden md:block">
@@ -80,7 +74,20 @@ export function Header() {
</Button>
</div>
{/* Mobile Submit Button -> triggers IconSubmissionForm dialog */}
<div className="md:hidden">
<IconSubmissionForm
trigger={
<Button variant="ghost" size="icon" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 ">
<PlusCircle className="h-5 w-5 transition-all duration-300" />
<span className="sr-only">Submit icon(s)</span>
</Button>
}
/>
</div>
<div className="hidden md:flex items-center gap-2 md:gap-4">
{/* Desktop Submit Button */}
<IconSubmissionForm />
<TooltipProvider>
<Tooltip>
@@ -109,6 +116,6 @@ export function Header() {
{/* Single instance of CommandMenu */}
{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />}
</motion.header>
</header>
)
}

View File

@@ -0,0 +1,121 @@
"use client"
import { Heart } from "lucide-react"
import { motion } from "framer-motion"
import { useState } from "react"
export function HeartEasterEgg() {
const [isHeartHovered, setIsHeartHovered] = useState(false)
const [isHeartFilled, setIsHeartFilled] = useState(false)
const handleHeartClick = () => {
setIsHeartFilled(!isHeartFilled)
}
return (
<div className="text-sm flex flex-wrap items-center gap-1.5 leading-relaxed">
Made with{" "}
<div className="relative inline-block">
<motion.div
className="cursor-pointer"
onMouseEnter={() => setIsHeartHovered(true)}
onMouseLeave={() => setIsHeartHovered(false)}
onClick={handleHeartClick}
whileTap={{ scale: 0.85 }}
>
<motion.div
animate={{
scale: isHeartFilled ? [1, 1.3, 1] : 1,
}}
transition={{
duration: isHeartFilled ? 0.4 : 0,
ease: "easeInOut",
}}
>
<Heart
className="h-3.5 w-3.5 flex-shrink-0 hover:scale-125 transition-all duration-200"
fill={isHeartFilled ? "#f43f5e" : "none"}
strokeWidth={isHeartFilled ? 1.5 : 2}
/>
</motion.div>
</motion.div>
{/* Easter egg mini hearts */}
{isHeartHovered && (
<>
{[...Array(8)].map((_, i) => (
<motion.div
key={i}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, (i % 2 === 0 ? 1 : -1) * Math.random() * 20],
y: [0, -Math.random() * 30],
}}
transition={{
duration: 0.8 + Math.random() * 0.5,
ease: "easeOut",
delay: Math.random() * 0.2,
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className={`h-2 w-2 ${i < 3 ? "text-rose-300" : i < 6 ? "text-rose-400" : ""}`} />
</motion.div>
))}
{/* Subtle particle glow */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 3],
opacity: [0, 0.3, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-rose-500/20 pointer-events-none"
/>
</>
)}
{/* Heart fill animation extras */}
{isHeartFilled && (
<>
{/* Radiating circles on heart fill */}
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: [0.5, 2.5],
opacity: [0.5, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 w-3 h-3 rounded-full bg-rose-500/30 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
/>
{/* Extra burst of mini hearts when filled */}
{[...Array(8)].map((_, i) => (
<motion.div
key={i}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, Math.cos((i * Math.PI) / 2.5) * 25],
y: [0, Math.sin((i * Math.PI) / 2.5) * 25],
}}
transition={{
duration: 0.6,
ease: "easeOut",
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className="h-2 w-2 " fill="#f43f5e" />
</motion.div>
))}
</>
)}
</div>{" "}
by Homarr Labs and the open source community.
</div>
)
}

View File

@@ -224,7 +224,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
<SearchInput searchQuery={searchQuery} setSearchQuery={setSearchQuery} totalIcons={totalIcons} />
<div className="w-full flex gap-3 md:gap-4 flex-wrap justify-center motion-preset-slide-down motion-duration-500">
<Link href="/icons">
<InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton>
<InteractiveHoverButton className="rounded-md bg-input/30">Browse icons</InteractiveHoverButton>
</Link>
<GiveUsAStarButton stars={stars} />
<GiveUsMoneyButton />
@@ -449,12 +449,12 @@ export function GiveUsMoneyButton() {
<div className="flex justify-between items-center pt-2">
<Link href={openCollectiveUrl} target="_blank" rel="noopener noreferrer">
<Button variant="default" size="sm" className="bg-primary hover:bg-primary/90">
Donate
Support
</Button>
</Link>
<Link href={`${openCollectiveUrl}/transactions`} target="_blank" rel="noopener noreferrer">
<Button variant="link" size="sm" className="flex items-center gap-1 text-xs text-secondary-foreground">
View expenses
View transactions
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
@@ -478,7 +478,7 @@ function SearchInput({ searchQuery, setSearchQuery, totalIcons }: SearchInputPro
name="q"
autoFocus
type="search"
placeholder={`Find any of ${totalIcons} icons by name or category...`}
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
className="pl-10 h-10 md:h-12 rounded-lg w-full border-border focus:border-primary/20 text-sm md:text-base"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}

View File

@@ -0,0 +1,36 @@
import { MagicCard } from "@/components/magicui/magic-card"
import { BASE_URL } from "@/constants"
import { formatIconName } from "@/lib/utils"
import type { Icon } from "@/types/icons"
import Image from "next/image"
import Link from "next/link"
import { preload } from "react-dom"
export function IconCard({
name,
data: iconData,
matchedAlias,
}: {
name: string
data: Icon
matchedAlias?: string
}) {
const formatedIconName = formatIconName(name)
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-16 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- dark:group-hover:text-primary transition-colors duration-200 font-medium">
{formatedIconName}
</span>
</Link>
</MagicCard>
)
}

View File

@@ -1,14 +1,17 @@
"use client"
import { IconsGrid } from "@/components/icon-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { BASE_URL, REPO_PATH } from "@/constants"
import type { AuthorData, Icon } from "@/types/icons"
import { formatIconName } from "@/lib/utils"
import type { AuthorData, Icon, IconFile } from "@/types/icons"
import confetti from "canvas-confetti"
import { motion } from "framer-motion"
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun, Type } from "lucide-react"
import { ArrowRight, Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
import dynamic from "next/dynamic"
import Image from "next/image"
import Link from "next/link"
import { useCallback, useState } from "react"
@@ -21,305 +24,10 @@ export type IconDetailsProps = {
icon: string
iconData: Icon
authorData: AuthorData
allIcons: IconFile
}
function IconVariant({
format,
iconName,
theme,
isWordmark = false,
iconData,
onCopy,
onDownload,
copiedVariants
}: {
format: string
iconName: string
theme?: "light" | "dark"
isWordmark?: boolean
iconData: Icon
onCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
onDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
}) {
let variantName = '';
if (isWordmark) {
if (theme && iconData.wordmark && typeof iconData.wordmark !== 'string') {
variantName = iconData.wordmark[theme] || `${iconName}_wordmark_${theme}`;
} else if (iconData.wordmark && typeof iconData.wordmark === 'string') {
variantName = iconData.wordmark;
} else {
variantName = `${iconName}_wordmark`;
}
} else {
if (theme && iconData.colors) {
variantName = iconData.colors[theme] || `${iconName}_${theme}`;
} else {
variantName = iconName;
}
}
const imageUrl = `${BASE_URL}/${format}/${variantName}.${format}`;
const githubUrl = `${REPO_PATH}/tree/main/${format}/${variantName}.${format}`;
const variantKey = `${format}-${theme || "default"}${isWordmark ? "-wordmark" : ""}`;
const isCopied = copiedVariants[variantKey] || false;
const btnCopied = copiedVariants[`btn-${variantKey}`] || false;
return (
<TooltipProvider delayDuration={500}>
<MagicCard className="p-0 rounded-md">
<div className="flex flex-col items-center p-4 transition-all">
<Tooltip>
<TooltipTrigger asChild>
<motion.div
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => onCopy(imageUrl, variantKey, e)}
>
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
<motion.div
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
initial={{ opacity: 0 }}
animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
{isCopied && (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: 1,
opacity: 1,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<Check className="w-8 h-8 text-primary" />
</motion.div>
)}
</motion.div>
<Image
src={imageUrl}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
className="object-contain p-4"
/>
</motion.div>
</TooltipTrigger>
<TooltipContent>
<p>Click to copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">{format.toUpperCase()}</p>
<div className="flex gap-2 mt-3 w-full justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => onDownload(e, imageUrl, `${iconName}.${format}`)}
>
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download icon file</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => onCopy(imageUrl, `btn-${variantKey}`, e)}
>
{btnCopied && <Check className="w-4 h-4 text-green-500" />}
{!btnCopied && <Copy className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</MagicCard>
</TooltipProvider>
)
}
function IconVariantsSection({
availableFormats,
icon,
theme,
isWordmark = false,
iconData,
handleCopy,
handleDownload,
copiedVariants,
title,
iconElement
}: {
availableFormats: string[]
icon: string
theme?: "light" | "dark"
isWordmark?: boolean
iconData: Icon
handleCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
handleDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
title: string
iconElement: React.ReactNode
}) {
return (
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
{iconElement}
{title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme={theme}
isWordmark={isWordmark}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)
}
function WordmarkSection({
iconData,
icon,
availableFormats,
handleCopy,
handleDownload,
copiedVariants
}: {
iconData: Icon
icon: string
availableFormats: string[]
handleCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
handleDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
}) {
if (!iconData.wordmark) return null;
const isStringWordmark = typeof iconData.wordmark === 'string';
const hasLightDarkVariants = !isStringWordmark && (iconData.wordmark.light || iconData.wordmark.dark);
return (
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<Type className="w-4 h-4 text-green-500" />
Wordmark variants
</h3>
{(isStringWordmark || !hasLightDarkVariants) && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
)}
{hasLightDarkVariants && (
<div className="space-y-6">
{iconData.wordmark.light && (
<div>
<h4 className="text-md font-medium flex items-center gap-2 ml-4 mb-2">
<Sun className="w-3 h-3 text-amber-500" />
Light
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme="light"
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)}
{iconData.wordmark.dark && (
<div>
<h4 className="text-md font-medium flex items-center gap-2 ml-4 mb-2">
<Moon className="w-3 h-3 text-indigo-500" />
Dark
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme="dark"
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) {
const authorName = authorData.name || authorData.login || ""
const iconColorVariants = iconData.colors
const iconWordmarkVariants = iconData.wordmark
@@ -425,23 +133,147 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
}
}
const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => {
const variantName = theme && iconColorVariants?.[theme] ? iconColorVariants[theme] : iconName
const imageUrl = `${BASE_URL}/${format}/${variantName}.${format}`
const githubUrl = `${REPO_PATH}/tree/main/${format}/${iconName}.${format}`
const variantKey = `${format}-${theme || "default"}`
const isCopied = copiedVariants[variantKey] || false
return (
<TooltipProvider key={variantKey} delayDuration={500}>
<MagicCard className="p-0 rounded-md">
<div className="flex flex-col items-center p-4 transition-all">
<Tooltip>
<TooltipTrigger asChild>
<motion.div
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => handleCopy(imageUrl, variantKey, e)}
aria-label={`Copy ${format.toUpperCase()} URL for ${iconName}${theme ? ` (${theme} theme)` : ""}`}
>
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
<motion.div
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
initial={{ opacity: 0 }}
animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: isCopied ? 1 : 0.5,
opacity: isCopied ? 1 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<Check className="w-8 h-8 text-primary" />
</motion.div>
</motion.div>
<Image
src={imageUrl}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
loading="eager"
className="object-contain p-4"
/>
</motion.div>
</TooltipTrigger>
<TooltipContent>
<p>Click to copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">{format.toUpperCase()}</p>
<div className="flex gap-2 mt-3 w-full justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)}
aria-label={`Download ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
>
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download icon file</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)}
aria-label={`Copy URL for ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
>
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`View ${iconName} ${format} file on GitHub`}
>
<Github className="w-4 h-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</MagicCard>
</TooltipProvider>
)
}
const formatedIconName = formatIconName(icon)
return (
<div className="container mx-auto pt-12 pb-14">
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<Card className="h-full bg-background/50 border shadow-lg">
<CardHeader className="pb-4">
<div className="flex flex-col items-center">
<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3 ">
<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3">
<Image
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
width={96}
height={96}
alt={icon}
placeholder="empty"
alt={`High quality ${formatedIconName} icon in ${iconData.base.toUpperCase()} format`}
className="w-full h-full object-contain"
/>
</div>
<CardTitle className="text-2xl font-bold capitalize text-center mb-2">{icon}</CardTitle>
<CardTitle className="text-2xl font-bold capitalize text-center mb-2">
<h1>{formatedIconName}</h1>
</CardTitle>
</div>
</CardHeader>
<CardContent>
@@ -450,14 +282,14 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="space-y-2">
<div className="flex items-center gap-2">
<p className="text-sm">
<span className="font-medium">Updated on:</span> {formattedDate}
<span className="font-medium">Updated on:</span> <time dateTime={iconData.update.timestamp}>{formattedDate}</time>
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">By:</p>
<Avatar className="h-5 w-5 border">
<AvatarImage src={authorData.avatar_url} alt={authorName} />
<AvatarImage src={authorData.avatar_url} alt={`${authorName}'s avatar`} />
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
</Avatar>
{authorData.html_url && (
@@ -480,7 +312,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{iconData.categories && iconData.categories.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground">Categories</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Categories</h3>
<div className="flex flex-wrap gap-2">
{iconData.categories.map((category) => (
<Link key={category} href={`/icons?category=${encodeURIComponent(category)}`} className="cursor-pointer">
@@ -501,7 +333,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{iconData.aliases && iconData.aliases.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground">Aliases</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Aliases</h3>
<div className="flex flex-wrap gap-2">
{iconData.aliases.map((alias) => (
<Badge
@@ -518,23 +350,20 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
)}
<div>
<h3 className="text-sm font-semibold text-muted-foreground">About this icon</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">About this icon</h3>
<div className="text-xs text-muted-foreground space-y-2">
<p>
Available in{" "}
{availableFormats.length > 1 && (
`${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})`
)}
{availableFormats.length === 1 && (
`${availableFormats[0].toUpperCase()} format`
)}{" "}
{availableFormats.length > 1
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")}) `
: `${availableFormats[0].toUpperCase()} format `}
with a base format of {iconData.base.toUpperCase()}.
{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."}
{iconData.wordmark && " Wordmark variants are also available for enhanced branding options."}
</p>
<p>
Use the {icon} icon in your web applications, dashboards, or documentation to enhance visual communication and user
experience.
Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {formatIconName(icon)}{" "}
logo.
</p>
</div>
</div>
@@ -546,7 +375,9 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="lg:col-span-2">
<Card className="h-full bg-background/50 shadow-lg">
<CardHeader>
<CardTitle>Icon variants</CardTitle>
<CardTitle>
<h2>Icon variants</h2>
</CardTitle>
<CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription>
</CardHeader>
<CardContent>
@@ -613,7 +444,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<CardContent>
<div className="space-y-6">
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Base format</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Base format</h3>
<div className="flex items-center gap-2">
<FileType className="w-4 h-4 text-blue-500" />
<div className="px-3 py-1.5 border border-border rounded-lg text-sm font-medium">{iconData.base.toUpperCase()}</div>
@@ -621,7 +452,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</div>
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Available formats</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Available formats</h3>
<div className="flex flex-wrap gap-2">
{availableFormats.map((format) => (
<div key={format} className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium">
@@ -633,7 +464,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{iconData.colors && (
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Color variants</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Color variants</h3>
<div className="space-y-2">
{Object.entries(iconData.colors).map(([theme, variant]) => (
<div key={theme} className="flex items-center gap-2">
@@ -669,7 +500,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
)}
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Source</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Source</h3>
<Button variant="outline" className="w-full" asChild>
<Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4 mr-2" />
@@ -683,6 +514,63 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</Card>
</div>
</div>
</div>
{iconData.categories &&
iconData.categories.length > 0 &&
(() => {
const MAX_RELATED_ICONS = 16
const currentCategories = iconData.categories || []
const relatedIconsWithScore = Object.entries(allIcons)
.map(([name, data]) => {
if (name === icon) return null // Exclude the current icon
const otherCategories = data.categories || []
const commonCategories = currentCategories.filter((cat) => otherCategories.includes(cat))
const score = commonCategories.length
return score > 0 ? { name, data, score } : null
})
.filter((item): item is { name: string; data: Icon; score: number } => item !== null) // Type guard
.sort((a, b) => b.score - a.score) // Sort by score DESC
const topRelatedIcons = relatedIconsWithScore.slice(0, MAX_RELATED_ICONS)
const viewMoreUrl = `/icons?${currentCategories.map((cat) => `category=${encodeURIComponent(cat)}`).join("&")}`
if (topRelatedIcons.length === 0) return null
return (
<section className="container mx-auto mt-12" aria-labelledby="related-icons-title">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>
<h2 id="related-icons-title">Related Icons</h2>
</CardTitle>
<CardDescription>
Other icons from {currentCategories.map((cat) => cat.replace(/-/g, " ")).join(", ")} categories
</CardDescription>
</CardHeader>
<CardContent>
<IconsGrid filteredIcons={topRelatedIcons} matchedAliases={{}} />
{relatedIconsWithScore.length > MAX_RELATED_ICONS && (
<div className="mt-6 text-center">
<Button
asChild
variant="link"
className="text-muted-foreground hover:text-primary transition-colors duration-200 hover:no-underline"
>
<Link href={viewMoreUrl} className="no-underline">
View all related icons
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</section>
)
})()}
</main>
)
}

View File

@@ -0,0 +1,87 @@
import type { Icon } from "@/types/icons"
import { useWindowVirtualizer } from "@tanstack/react-virtual"
import { useEffect, useMemo, useRef, useState } from "react"
import { IconCard } from "./icon-card"
interface IconsGridProps {
filteredIcons: { name: string; data: Icon }[]
matchedAliases: Record<string, string>
}
export 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]} />
))}
</div>
)
}
export function VirtualizedIconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
const listRef = useRef<HTMLDivElement | null>(null)
const [windowWidth, setWindowWidth] = useState(0)
useEffect(() => {
setWindowWidth(window.innerWidth)
const handleResize = () => {
setWindowWidth(window.innerWidth)
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
const columnCount = useMemo(() => {
if (windowWidth >= 1280) return 8 // xl
if (windowWidth >= 1024) return 6 // lg
if (windowWidth >= 768) return 4 // md
if (windowWidth >= 640) return 3 // sm
return 2 // default
}, [windowWidth])
const rowCount = Math.ceil(filteredIcons.length / columnCount)
const rowVirtualizer = useWindowVirtualizer({
count: rowCount,
estimateSize: () => 140,
overscan: 2,
})
return (
<div ref={listRef} className="mt-2">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const rowStart = virtualRow.index * columnCount
const rowEnd = Math.min(rowStart + columnCount, filteredIcons.length)
const rowIcons = filteredIcons.slice(rowStart, rowEnd)
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
minHeight: 124,
width: "100%",
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
}}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4"
>
{rowIcons.map(({ name, data }) => (
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name]} />
))}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,406 @@
"use client"
import { VirtualizedIconsGrid } from "@/components/icon-grid"
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,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { type SortOption, filterAndSortIcons } from "@/lib/utils"
import type { IconSearchProps } from "@/types/icons"
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"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
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 [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "")
const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? [])
const [sortOption, setSortOption] = useState<SortOption>(initialSort)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const { resolvedTheme } = useTheme()
const [isLazyRequestSubmitted, setIsLazyRequestSubmitted] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery)
}, 200)
return () => clearTimeout(timer)
}, [searchQuery])
// Extract all unique categories
const allCategories = useMemo(() => {
const categories = new Set<string>()
for (const icon of icons) {
for (const category of icon.data.categories) {
categories.add(category)
}
}
return Array.from(categories).sort()
}, [icons])
// Find matched aliases for display purposes
const matchedAliases = useMemo(() => {
if (!searchQuery.trim()) return {}
const q = searchQuery.toLowerCase()
const matches: Record<string, string> = {}
for (const { name, data } of icons) {
// If name doesn't match but an alias does, store the first matching alias
if (!name.toLowerCase().includes(q)) {
const matchingAlias = data.aliases.find((alias) => alias.toLowerCase().includes(q))
if (matchingAlias) {
matches[name] = matchingAlias
}
}
}
return matches
}, [icons, searchQuery])
// Use useMemo for filtered icons with debounced query
const filteredIcons = useMemo(() => {
return filterAndSortIcons({
icons,
query: debouncedQuery,
categories: selectedCategories,
sort: sortOption,
})
}, [icons, debouncedQuery, selectedCategories, sortOption])
const updateResults = useCallback(
(query: string, categories: string[], sort: SortOption) => {
const params = new URLSearchParams()
if (query) params.set("q", query)
// 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 })
},
[pathname, router, initialSort],
)
const handleSearch = useCallback(
(query: string) => {
setSearchQuery(query)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
updateResults(query, selectedCategories, sortOption)
}, 200) // Changed from 100ms to 200ms
},
[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) {
clearTimeout(timeoutRef.current)
}
}
}, [])
useEffect(() => {
if (filteredIcons.length === 0 && searchQuery) {
console.log("no icons found", {
query: searchQuery,
})
posthog.capture("no icons found", {
query: searchQuery,
})
}
}, [filteredIcons, searchQuery])
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 <Search className="h-4 w-4" />
case "alphabetical-asc":
return <ArrowDownAZ className="h-4 w-4" />
case "alphabetical-desc":
return <ArrowUpZA className="h-4 w-4" />
case "newest":
return <Calendar className="h-4 w-4" />
default:
return <SortAsc className="h-4 w-4" />
}
}
return (
<>
<div className="space-y-4 w-full">
{/* 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 icons by name, alias, or category..."
className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm"
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 ">
<Filter className="h-4 w-4 mr-2" />
<span>Filter</span>
{selectedCategories.length > 0 && (
<Badge variant="secondary" className="ml-2 px-1.5">
{selectedCategories.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 sm:w-56">
<DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel>
<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"
>
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</DropdownMenuCheckboxItem>
))}
</div>
{selectedCategories.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setSelectedCategories([])
updateResults(searchQuery, [], sortOption)
}}
className="cursor-pointer focus: focus:bg-rose-50 dark:focus:bg-rose-950/20"
>
Clear categories
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Sort dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm">
{getSortIcon(sortOption)}
<span className="ml-2">{getSortLabel(sortOption)}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
<Search className="h-4 w-4 mr-2" />
Relevance
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer">
<ArrowDownAZ className="h-4 w-4 mr-2" />
Name (A-Z)
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer">
<ArrowUpZA className="h-4 w-4 mr-2" />
Name (Z-A)
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
<Calendar className="h-4 w-4 mr-2" />
Newest first
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Clear all button */}
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
<Button variant="outline" size="sm" onClick={clearFilters} className="flex-1 sm:flex-none cursor-pointer bg-background">
<X className="h-4 w-4 mr-2" />
<span>Reset all</span>
</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>
<div className="flex flex-wrap gap-2">
{selectedCategories.map((category) => (
<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())}
<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={() => {
setSelectedCategories([])
updateResults(searchQuery, [], sortOption)
}}
className="text-xs h-7 px-2 cursor-pointer"
>
Clear
</Button>
</div>
)}
<Separator className="my-2" />
</div>
{filteredIcons.length === 0 ? (
<div className="flex flex-col gap-8 py-12 px-2 w-full max-w-full sm:max-w-2xl mx-auto items-center overflow-x-hidden">
<div className="text-center w-full">
<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">
<div id="icon-submission-content" className="w-full">
<IconSubmissionContent />
</div>
<div className="mt-4 flex flex-col sm:flex-row items-center gap-2 justify-center w-full">
<span className="text-sm text-muted-foreground">Can't submit it yourself?</span>
<Button
className="cursor-pointer w-full sm:w-auto truncate whitespace-nowrap"
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>
</div>
) : (
<>
<div className="flex justify-between items-center pb-2">
<p className="text-sm text-muted-foreground">
Found {filteredIcons.length} icon
{filteredIcons.length !== 1 ? "s" : ""}.
</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{getSortIcon(sortOption)}
<span>{getSortLabel(sortOption)}</span>
</div>
</div>
<VirtualizedIconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} />
</>
)}
</>
)
}

View File

@@ -52,12 +52,12 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
className="w-full flex flex-col items-start gap-1 h-auto p-4 text-left cursor-pointer transition-all duration-300"
asChild
>
<div>
<div className="w-full">
<div className="flex w-full items-center justify-between">
<span className="font-medium transition-all duration-300">{template.name}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground transition-all duration-300" />
<span className="font-medium transition-all duration-300 whitespace-normal text-wrap">{template.name}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground transition-all duration-300 flex-shrink-0 ml-2" />
</div>
<span className="text-xs text-muted-foreground">{template.description}</span>
<span className="text-xs text-muted-foreground whitespace-normal text-wrap break-words">{template.description}</span>
</div>
</Button>
</Link>
@@ -66,22 +66,26 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
</div>
)
}
export function IconSubmissionForm() {
export function IconSubmissionForm({ trigger }: { trigger?: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300">
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Contribute new icon
</Button>
</DialogTrigger>
<DialogContent className="md:max-w-4xl backdrop-blur-2xl bg-background">
{trigger ? (
<DialogTrigger asChild>{trigger}</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300 items-center gap-2">
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Submit icon(s)
</Button>
</DialogTrigger>
)}
<DialogContent className="w-[calc(100%-2rem)] max-w-sm md:w-full md:max-w-4xl p-6 backdrop-blur-2xl bg-background flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Contribute a new icon</DialogTitle>
<DialogDescription>Choose a template below to suggest a new icon or improve an existing one.</DialogDescription>
<DialogTitle>Submit an icon</DialogTitle>
<DialogDescription>Select an option below to submit or update an icon.</DialogDescription>
</DialogHeader>
<div className="mt-4">
<div className="overflow-y-auto max-h-[calc(85vh-10rem)] pr-2">
<IconSubmissionContent onClose={() => setOpen(false)} />
</div>
</DialogContent>

View File

@@ -1,33 +0,0 @@
import type { CSSProperties, ComponentPropsWithoutRef, FC } from "react"
import { cn } from "@/lib/utils"
export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> {
shimmerWidth?: number
}
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({ children, className, shimmerWidth = 100, ...props }) => {
return (
<span
style={
{
"--shiny-width": `${shimmerWidth}px`,
} as CSSProperties
}
className={cn(
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
// Shine effect
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
// Shine gradient
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
className,
)}
{...props}
>
{children}
</span>
)
}

View File

@@ -2,7 +2,7 @@
import { Marquee } from "@/components/magicui/marquee"
import { BASE_URL } from "@/constants"
import { cn } from "@/lib/utils"
import { cn, formatIconName } from "@/lib/utils"
import type { Icon, IconWithName } from "@/types/icons"
import { format, isToday, isYesterday } from "date-fns"
import { ArrowRight, Clock, ExternalLink } from "lucide-react"
@@ -61,7 +61,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
href="/icons"
className="font-medium inline-flex items-center py-2 px-4 rounded-full border transition-all duration-200 group hover-lift soft-shadow"
>
View complete collection
View all icons
<ArrowRight className="w-4 h-4 ml-1.5 transition-transform duration-200 group-hover:translate-x-1" />
</Link>
</div>
@@ -78,6 +78,7 @@ function RecentIconCard({
name: string
data: Icon
}) {
const formattedIconName = formatIconName(name)
return (
<Link
prefetch={false}
@@ -85,8 +86,9 @@ function RecentIconCard({
className={cn(
"flex flex-col items-center p-3 sm:p-4 rounded-xl border border-border",
"transition-all duration-300 hover:shadow-lg hover:shadow-rose-500/5 relative overflow-hidden hover-lift",
"w-36 mx-2",
"w-36 mx-2 group/item",
)}
aria-label={`View details for ${formattedIconName} icon`}
>
<div className="absolute inset-0 bg-gradient-to-br from-rose-500/5 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300" />
@@ -99,7 +101,7 @@ function RecentIconCard({
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize dark:hover:text-rose-400 transition-colors duration-200 font-medium">
{name.replace(/-/g, " ")}
{formattedIconName}
</span>
<div className="flex items-center justify-center mt-2 w-full">
<span className="text-[10px] sm:text-xs text-muted-foreground flex items-center whitespace-nowrap hover:/70 transition-colors duration-200">
@@ -108,7 +110,7 @@ function RecentIconCard({
</span>
</div>
<div className="absolute top-2 right-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
<div className="absolute top-2 right-2 opacity-0 group-hover/item:opacity-100 transition-opacity duration-200">
<ExternalLink className="w-3 h-3 " />
</div>
</Link>

View File

@@ -33,10 +33,12 @@ function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
contentClassName,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
contentClassName?: string
}) {
return (
<Dialog {...props}>
@@ -44,7 +46,7 @@ function CommandDialog({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<DialogContent className={cn("overflow-hidden p-0", contentClassName)}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>