Merge branch 'main' into feat/wordmark-icons
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
121
web/src/components/heart.tsx
Normal file
121
web/src/components/heart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
36
web/src/components/icon-card.tsx
Normal file
36
web/src/components/icon-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
87
web/src/components/icon-grid.tsx
Normal file
87
web/src/components/icon-grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
406
web/src/components/icon-search.tsx
Normal file
406
web/src/components/icon-search.tsx
Normal 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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user