mirror of
				https://github.com/walkxcode/dashboard-icons.git
				synced 2025-10-31 08:47:57 +01:00 
			
		
		
		
	revert: revert changes
This commit is contained in:
		| @@ -42,6 +42,7 @@ | ||||
| 		"canvas-confetti": "^1.9.3", | ||||
| 		"class-variance-authority": "^0.7.1", | ||||
| 		"clsx": "^2.1.1", | ||||
| 		"cmdk": "^1.1.1", | ||||
| 		"date-fns": "^4.1.0", | ||||
| 		"embla-carousel-react": "^8.6.0", | ||||
| 		"framer-motion": "^12.7.3", | ||||
|   | ||||
							
								
								
									
										21
									
								
								web/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										21
									
								
								web/pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @@ -101,6 +101,9 @@ importers: | ||||
|       clsx: | ||||
|         specifier: ^2.1.1 | ||||
|         version: 2.1.1 | ||||
|       cmdk: | ||||
|         specifier: ^1.1.1 | ||||
|         version: 1.1.1(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) | ||||
|       date-fns: | ||||
|         specifier: ^4.1.0 | ||||
|         version: 4.1.0 | ||||
| @@ -1549,6 +1552,12 @@ packages: | ||||
|     resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} | ||||
|     engines: {node: '>=6'} | ||||
|  | ||||
|   cmdk@1.1.1: | ||||
|     resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} | ||||
|     peerDependencies: | ||||
|       react: ^18 || ^19 || ^19.0.0-rc | ||||
|       react-dom: ^18 || ^19 || ^19.0.0-rc | ||||
|  | ||||
|   color-convert@2.0.1: | ||||
|     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} | ||||
|     engines: {node: '>=7.0.0'} | ||||
| @@ -3424,6 +3433,18 @@ snapshots: | ||||
|  | ||||
|   clsx@2.1.1: {} | ||||
|  | ||||
|   cmdk@1.1.1(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): | ||||
|     dependencies: | ||||
|       '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.0)(react@19.1.0) | ||||
|       '@radix-ui/react-dialog': 1.1.7(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) | ||||
|       '@radix-ui/react-id': 1.1.1(@types/react@19.1.0)(react@19.1.0) | ||||
|       '@radix-ui/react-primitive': 2.0.3(@types/react-dom@19.1.2(@types/react@19.1.0))(@types/react@19.1.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) | ||||
|       react: 19.1.0 | ||||
|       react-dom: 19.1.0(react@19.1.0) | ||||
|     transitivePeerDependencies: | ||||
|       - '@types/react' | ||||
|       - '@types/react-dom' | ||||
|  | ||||
|   color-convert@2.0.1: | ||||
|     dependencies: | ||||
|       color-name: 1.1.4 | ||||
|   | ||||
| @@ -1,13 +1,22 @@ | ||||
| import { IconDetails } from "@/components/icon-details" | ||||
| import { BASE_URL, WEB_URL } from "@/constants" | ||||
| import { getAllIcons, getAuthorData } from "@/lib/api" | ||||
| import { formatIconName } from "@/lib/utils" | ||||
| import type { Metadata, ResolvingMetadata } from "next" | ||||
| import { default as dynamicImport } from "next/dynamic" | ||||
| import { notFound } from "next/navigation" | ||||
|  | ||||
| export const dynamicParams = false | ||||
|  | ||||
| export async function generateStaticParams() { | ||||
| 	const iconsData = await getAllIcons() | ||||
| 	if (process.env.CI_MODE === "false") { | ||||
| 		// This is meant to speed up the build process in local development | ||||
| 		return Object.keys(iconsData) | ||||
| 			.slice(0, 5) | ||||
| 			.map((icon) => ({ | ||||
| 				icon, | ||||
| 			})) | ||||
| 	} | ||||
| 	return Object.keys(iconsData).map((icon) => ({ | ||||
| 		icon, | ||||
| 	})) | ||||
| @@ -33,7 +42,6 @@ export async function generateMetadata({ params, searchParams }: Props, parent: | ||||
|  | ||||
| 	console.debug(`Generated metadata for ${icon} by ${authorName} (${authorData.html_url}) updated at ${updateDate.toLocaleString()}`) | ||||
|  | ||||
| 	const iconPreviewImageUrl = `${BASE_URL}/webp/${icon}.webp` | ||||
| 	const pageUrl = `${WEB_URL}/icons/${icon}` | ||||
| 	const formattedIconName = icon | ||||
| 		.split("-") | ||||
| @@ -61,7 +69,7 @@ export async function generateMetadata({ params, searchParams }: Props, parent: | ||||
| 			"app directory", | ||||
| 		], | ||||
| 		icons: { | ||||
| 			icon: iconPreviewImageUrl, | ||||
| 			icon: `${BASE_URL}/webp/${icon}.webp`, | ||||
| 		}, | ||||
| 		abstract: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, | ||||
| 		openGraph: { | ||||
| @@ -74,13 +82,11 @@ export async function generateMetadata({ params, searchParams }: Props, parent: | ||||
| 			modifiedTime: updateDate.toISOString(), | ||||
| 			section: "Icons", | ||||
| 			tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"], | ||||
| 			images: [iconPreviewImageUrl], | ||||
| 		}, | ||||
| 		twitter: { | ||||
| 			card: "summary_large_image", | ||||
| 			title: `${formattedIconName} Icon | Dashboard Icons`, | ||||
| 			description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, | ||||
| 			images: [iconPreviewImageUrl], | ||||
| 		}, | ||||
| 		alternates: { | ||||
| 			canonical: pageUrl, | ||||
|   | ||||
| @@ -10,28 +10,28 @@ export const size = { | ||||
|  | ||||
| // Define a fixed list of representative icons | ||||
| const representativeIcons = [ | ||||
| 	"github", | ||||
| 	"discord", | ||||
| 	"slack", | ||||
| 	"docker", | ||||
| 	"kubernetes", | ||||
| 	"grafana", | ||||
| 	"prometheus", | ||||
| 	"nextcloud", | ||||
| 	"homeassistant", | ||||
| 	"homarr", | ||||
| 	"sonarr", | ||||
| 	"radarr", | ||||
| 	"lidarr", | ||||
| 	"readarr", | ||||
| 	"prowlarr", | ||||
| 	"qbittorrent", | ||||
| 	"home-assistant", | ||||
| 	"cloudflare", | ||||
| 	"nginx", | ||||
| 	"github", | ||||
| 	"traefik", | ||||
| 	"portainer", | ||||
| 	"plex", | ||||
| 	"jellyfin", | ||||
| 	"overseerr", | ||||
| ] | ||||
|  | ||||
| export default async function Image() { | ||||
| 	const iconsData = await getAllIcons() | ||||
| 	const totalIcons = Object.keys(iconsData).length | ||||
| 	// Round down to the nearest 100 | ||||
| 	const roundedTotalIcons = Math.floor(totalIcons / 100) * 100 | ||||
| 	const roundedTotalIcons = Math.round(totalIcons / 100) * 100 | ||||
|  | ||||
| 	const iconImages = representativeIcons.map((icon) => ({ | ||||
| 		name: icon | ||||
|   | ||||
| @@ -25,21 +25,11 @@ export async function generateMetadata(): Promise<Metadata> { | ||||
| 			description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, | ||||
| 			type: "website", | ||||
| 			url: `${BASE_URL}/icons`, | ||||
| 			images: [ | ||||
| 				{ | ||||
| 					url: "/og-image.png", | ||||
| 					width: 1200, | ||||
| 					height: 630, | ||||
| 					alt: "Browse Dashboard Icons Collection", | ||||
| 					type: "image/png", | ||||
| 				}, | ||||
| 			], | ||||
| 		}, | ||||
| 		twitter: { | ||||
| 			card: "summary_large_image", | ||||
| 			title: "Browse Icons | Free Dashboard Icons", | ||||
| 			description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`, | ||||
| 			images: ["/og-image-browse.png"], | ||||
| 		}, | ||||
| 		alternates: { | ||||
| 			canonical: `${BASE_URL}/icons`, | ||||
|   | ||||
| @@ -2,12 +2,12 @@ import { PostHogProvider } from "@/components/PostHogProvider" | ||||
| import { Footer } from "@/components/footer" | ||||
| import { HeaderWrapper } from "@/components/header-wrapper" | ||||
| import { LicenseNotice } from "@/components/license-notice" | ||||
| import { BASE_URL, WEB_URL, getDescription, websiteTitle } from "@/constants" | ||||
| import { getTotalIcons } from "@/lib/api" | ||||
| import type { Metadata, Viewport } from "next" | ||||
| import { Inter } from "next/font/google" | ||||
| import { Toaster } from "sonner" | ||||
| import "./globals.css" | ||||
| import { BASE_URL, WEB_URL, getDescription, websiteTitle } from "@/constants" | ||||
| import { ThemeProvider } from "./theme-provider" | ||||
|  | ||||
| const inter = Inter({ | ||||
| @@ -82,6 +82,7 @@ export async function generateMetadata(): Promise<Metadata> { | ||||
| } | ||||
|  | ||||
| export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { | ||||
|  | ||||
| 	return ( | ||||
| 		<html lang="en" suppressHydrationWarning> | ||||
| 			<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}> | ||||
|   | ||||
							
								
								
									
										138
									
								
								web/src/components/command-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								web/src/components/command-menu.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,138 @@ | ||||
| "use client" | ||||
|  | ||||
| import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" | ||||
| import { useMediaQuery } from "@/hooks/use-media-query" | ||||
| import { formatIconName, fuzzySearch } from "@/lib/utils" | ||||
| import { useRouter } from "next/navigation" | ||||
| import { useCallback, useEffect, useState } from "react" | ||||
|  | ||||
| interface CommandMenuProps { | ||||
| 	icons: { | ||||
| 		name: string | ||||
| 		data: { | ||||
| 			categories: string[] | ||||
| 			aliases: string[] | ||||
| 			[key: string]: unknown | ||||
| 		} | ||||
| 	}[] | ||||
| 	triggerButtonId?: string | ||||
| 	open?: boolean | ||||
| 	onOpenChange?: (open: boolean) => void | ||||
| } | ||||
|  | ||||
| export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalOnOpenChange }: CommandMenuProps) { | ||||
| 	const router = useRouter() | ||||
| 	const [internalOpen, setInternalOpen] = useState(false) | ||||
| 	const [query, setQuery] = useState("") | ||||
| 	const isDesktop = useMediaQuery("(min-width: 768px)") | ||||
|  | ||||
| 	// Use either external or internal state for controlling open state | ||||
| 	const isOpen = externalOpen !== undefined ? externalOpen : internalOpen | ||||
|  | ||||
| 	// Wrap setIsOpen in useCallback to fix dependency issue | ||||
| 	const setIsOpen = useCallback( | ||||
| 		(value: boolean) => { | ||||
| 			if (externalOnOpenChange) { | ||||
| 				externalOnOpenChange(value) | ||||
| 			} else { | ||||
| 				setInternalOpen(value) | ||||
| 			} | ||||
| 		}, | ||||
| 		[externalOnOpenChange], | ||||
| 	) | ||||
|  | ||||
| 	const filteredIcons = getFilteredIcons(icons, query) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const handleKeyDown = (e: KeyboardEvent) => { | ||||
| 			if ( | ||||
| 				(e.key === "k" && (e.metaKey || e.ctrlKey)) || | ||||
| 				(e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") | ||||
| 			) { | ||||
| 				e.preventDefault() | ||||
| 				setIsOpen(!isOpen) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		document.addEventListener("keydown", handleKeyDown) | ||||
| 		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 formatedIconName = formatIconName(name) | ||||
|  | ||||
| 						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> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 								<span className="flex-grow capitalize">{formatedIconName}</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> | ||||
| 						) | ||||
| 					})} | ||||
| 				</CommandGroup> | ||||
| 			</CommandList> | ||||
| 		</CommandDialog> | ||||
| 	) | ||||
| } | ||||
| @@ -3,16 +3,45 @@ | ||||
| import { IconSubmissionForm } from "@/components/icon-submission-form" | ||||
| import { ThemeSwitcher } from "@/components/theme-switcher" | ||||
| import { REPO_PATH } from "@/constants" | ||||
| import { motion } from "framer-motion" | ||||
| import { Github } from "lucide-react" | ||||
| import { getIconsArray } from "@/lib/api" | ||||
| import type { IconWithName } from "@/types/icons" | ||||
| import { Github, Search } from "lucide-react" | ||||
| import Link from "next/link" | ||||
| import { useEffect, useState } from "react" | ||||
| import { CommandMenu } from "./command-menu" | ||||
| import { HeaderNav } from "./header-nav" | ||||
| import { Button } from "./ui/button" | ||||
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip" | ||||
|  | ||||
| export function Header() { | ||||
| 	const [iconsData, setIconsData] = useState<IconWithName[]>([]) | ||||
| 	const [isLoaded, setIsLoaded] = useState(false) | ||||
| 	const [commandMenuOpen, setCommandMenuOpen] = useState(false) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		async function loadIcons() { | ||||
| 			try { | ||||
| 				const icons = await getIconsArray() | ||||
| 				setIconsData(icons) | ||||
| 				setIsLoaded(true) | ||||
| 			} catch (error) { | ||||
| 				console.error("Failed to load icons:", error) | ||||
| 				setIsLoaded(true) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		loadIcons() | ||||
| 	}, []) | ||||
|  | ||||
| 	// Function to open the command menu | ||||
| 	const openCommandMenu = () => { | ||||
| 		setCommandMenuOpen(true) | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50"> | ||||
| 		<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"> | ||||
| @@ -23,6 +52,30 @@ export function Header() { | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div className="flex items-center gap-2 md:gap-4"> | ||||
| 					{/* Desktop search button */} | ||||
| 					<div className="hidden md:block"> | ||||
| 						<Button variant="outline" className="gap-2 cursor-pointer   transition-all duration-300" onClick={openCommandMenu}> | ||||
| 							<Search className="h-4 w-4 transition-all duration-300" /> | ||||
| 							<span>Find icons</span> | ||||
| 							<kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border border-border/80 bg-muted/80 px-1.5 font-mono text-[10px] font-medium opacity-100"> | ||||
| 								<span className="text-xs">⌘</span>K | ||||
| 							</kbd> | ||||
| 						</Button> | ||||
| 					</div> | ||||
|  | ||||
| 					{/* Mobile search button */} | ||||
| 					<div className="md:hidden"> | ||||
| 						<Button | ||||
| 							variant="ghost" | ||||
| 							size="icon" | ||||
| 							className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 " | ||||
| 							onClick={openCommandMenu} | ||||
| 						> | ||||
| 							<Search className="h-5 w-5 transition-all duration-300" /> | ||||
| 							<span className="sr-only">Find icons</span> | ||||
| 						</Button> | ||||
| 					</div> | ||||
|  | ||||
| 					<div className="hidden md:flex items-center gap-2 md:gap-4"> | ||||
| 						<IconSubmissionForm /> | ||||
| 						<TooltipProvider> | ||||
| @@ -49,6 +102,9 @@ export function Header() { | ||||
| 					<ThemeSwitcher /> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
|  | ||||
| 			{/* Single instance of CommandMenu */} | ||||
| 			{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />} | ||||
| 		</header> | ||||
| 	) | ||||
| } | ||||
| } | ||||
| @@ -205,61 +205,13 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars: | ||||
| 				/> | ||||
| 			</div> | ||||
|  | ||||
| 			<div className="relative z-10 container mx-auto px-4 sm:px-6 lg:px-8 mt-4 py-20"> | ||||
| 			<div className="relative z-10 container mx-auto px-4 md:px-6 mt-4 py-20"> | ||||
| 				<div className="max-w-4xl mx-auto text-center flex flex-col gap-4 "> | ||||
| 					<h1 className="relative text-3xl sm:text-5xl md:text-7xl font-bold mb-4 md:mb-8 tracking-tight motion-preset-slide-up motion-duration-500 "> | ||||
| 						Your definitive source for | ||||
| 						<motion.span | ||||
| 							className="absolute -right-1 -bottom-3" | ||||
| 							initial={{ opacity: 0, scale: 0.5, x: -20, y: -10 }} | ||||
| 							animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} | ||||
| 							transition={{ | ||||
| 								duration: 0.5, | ||||
| 								delay: 0.3, | ||||
| 								ease: "easeOut", | ||||
| 							}} | ||||
| 						> | ||||
| 							<motion.div | ||||
| 								animate={{ | ||||
| 									y: [0, -3, 0], | ||||
| 									rotate: [0, 5, 0], | ||||
| 								}} | ||||
| 								transition={{ | ||||
| 									duration: 3, | ||||
| 									repeat: Number.POSITIVE_INFINITY, | ||||
| 									repeatType: "reverse", | ||||
| 									ease: "easeInOut", | ||||
| 								}} | ||||
| 							> | ||||
| 								<Sparkles className="text-rose-500 h-8 w-8 sm:h-12 sm:w-12 md:h-16 md:w-12" /> | ||||
| 							</motion.div> | ||||
| 						</motion.span> | ||||
| 						<Sparkles className="absolute -right-1 -bottom-3 text-rose-500 h-8 w-8 sm:h-12 sm:w-12 md:h-16 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[-120%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-500 motion-delay-[0.13s]/scale motion-duration-[0.13s]/opacity motion-duration-[0.40s]/rotate motion-duration-[0.05s]/blur motion-delay-[0.20s]/blur motion-ease-spring-bouncier" /> | ||||
| 						<br /> | ||||
| 						<motion.span | ||||
| 							className="absolute -left-1 -top-3" | ||||
| 							initial={{ opacity: 0, scale: 0.5, x: 20, y: -10 }} | ||||
| 							animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} | ||||
| 							transition={{ | ||||
| 								duration: 0.5, | ||||
| 								delay: 0.3, | ||||
| 								ease: "easeOut", | ||||
| 							}} | ||||
| 						> | ||||
| 							<motion.div | ||||
| 								animate={{ | ||||
| 									y: [0, -3, 0], | ||||
| 									rotate: [0, -5, 0], | ||||
| 								}} | ||||
| 								transition={{ | ||||
| 									duration: 4, | ||||
| 									repeat: Number.POSITIVE_INFINITY, | ||||
| 									repeatType: "reverse", | ||||
| 									ease: "easeInOut", | ||||
| 								}} | ||||
| 							> | ||||
| 								<Sparkles className="text-rose-500 h-5 w-5 sm:h-8 sm:w-8 md:h-12 md:w-12" /> | ||||
| 							</motion.div> | ||||
| 						</motion.span> | ||||
| 						<Sparkles className="absolute -left-1 -top-3 text-rose-500 h-5 w-5 sm:h-8 sm:w-8 md:h-12 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[159%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-500 motion-delay-[0.13s]/scale motion-duration-[0.13s]/opacity motion-duration-[0.40s]/rotate motion-duration-[0.05s]/blur motion-delay-[0.20s]/blur motion-ease-spring-bouncier" /> | ||||
| 						<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText> | ||||
| 					</h1> | ||||
|  | ||||
| @@ -272,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">Browse icons</InteractiveHoverButton> | ||||
| 								<InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton> | ||||
| 							</Link> | ||||
| 							<GiveUsAStarButton stars={stars} /> | ||||
| 							<GiveUsMoneyButton /> | ||||
| @@ -526,7 +478,7 @@ function SearchInput({ searchQuery, setSearchQuery, totalIcons }: SearchInputPro | ||||
| 				name="q" | ||||
| 				autoFocus | ||||
| 				type="search" | ||||
| 				placeholder={`Search our collection of ${totalIcons} icons by name or category...`} | ||||
| 				placeholder={`Find any 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)} | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| 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 { useState } from "react" | ||||
| import { AlertTriangle } from "lucide-react" | ||||
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" | ||||
| import { preload } from "react-dom" | ||||
|  | ||||
| export function IconCard({ | ||||
| 	name, | ||||
| @@ -16,58 +15,20 @@ export function IconCard({ | ||||
| 	data: Icon | ||||
| 	matchedAlias?: string | ||||
| }) { | ||||
| 	const [isLoading, setIsLoading] = useState(true) | ||||
| 	const [hasError, setHasError] = useState(false) | ||||
|  | ||||
| 	// Construct URLs for both WebP and the original format | ||||
| 	const webpSrc = `${BASE_URL}/webp/${name}.webp` | ||||
| 	const originalSrc = `${BASE_URL}/${iconData.base}/${name}.${iconData.base}` | ||||
|  | ||||
| 	const handleLoadingComplete = () => { | ||||
| 		setIsLoading(false) | ||||
| 		setHasError(false) | ||||
| 	} | ||||
|  | ||||
| 	const handleError = () => { | ||||
| 		setIsLoading(false) | ||||
| 		setHasError(true) | ||||
| 	} | ||||
|  | ||||
| 	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 flex items-center justify-center"> | ||||
| 					{isLoading && !hasError && ( | ||||
| 						<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded" /> | ||||
| 					)} | ||||
| 					{hasError ? ( | ||||
| 						<TooltipProvider delayDuration={300}> | ||||
| 							<Tooltip> | ||||
| 								<TooltipTrigger aria-label="Image loading error"> | ||||
| 									<AlertTriangle className="h-8 w-8 text-red-500 cursor-help" /> | ||||
| 								</TooltipTrigger> | ||||
| 								<TooltipContent side="bottom"> | ||||
| 									<p>Image failed to load, likely due to size limits. Please raise an issue on GitHub.</p> | ||||
| 								</TooltipContent> | ||||
| 							</Tooltip> | ||||
| 						</TooltipProvider> | ||||
| 					) : ( | ||||
| 						<picture> | ||||
| 							<source srcSet={webpSrc} type="image/webp" /> | ||||
| 							<source srcSet={originalSrc} type={`image/${iconData.base === 'svg' ? 'svg+xml' : iconData.base}`} /> | ||||
| 							<Image | ||||
| 								src={originalSrc} | ||||
| 								alt={`${name} icon`} | ||||
| 								fill | ||||
| 								className={`object-contain p-1 group-hover:scale-110 transition-transform duration-300 ${isLoading || hasError ? 'opacity-0' : 'opacity-100 transition-opacity duration-500'}`} | ||||
| 								onLoadingComplete={handleLoadingComplete} | ||||
| 								onError={handleError} | ||||
| 							/> | ||||
| 						</picture> | ||||
| 					)} | ||||
| 				<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"> | ||||
| 					{name.replace(/-/g, " ")} | ||||
| 					{formatedIconName} | ||||
| 				</span> | ||||
|  | ||||
| 				{matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>} | ||||
|   | ||||
| @@ -9,7 +9,8 @@ import { BASE_URL, REPO_PATH } from "@/constants" | ||||
| import type { AuthorData, Icon, IconFile } from "@/types/icons" | ||||
| import confetti from "canvas-confetti" | ||||
| import { motion } from "framer-motion" | ||||
| import { ArrowRight, Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun, AlertTriangle } from "lucide-react" | ||||
| import { 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" | ||||
| @@ -17,6 +18,7 @@ import { toast } from "sonner" | ||||
| import { Carbon } from "./carbon" | ||||
| import { MagicCard } from "./magicui/magic-card" | ||||
| import { Badge } from "./ui/badge" | ||||
| import { formatIconName } from "@/lib/utils" | ||||
|  | ||||
| export type IconDetailsProps = { | ||||
| 	icon: string | ||||
| @@ -26,10 +28,6 @@ export type IconDetailsProps = { | ||||
| } | ||||
|  | ||||
| export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) { | ||||
| 	// Add state for the main preview icon | ||||
| 	const [isPreviewLoading, setIsPreviewLoading] = useState(true) | ||||
| 	const [hasPreviewError, setHasPreviewError] = useState(false) | ||||
|  | ||||
| 	const authorName = authorData.name || authorData.login || "" | ||||
| 	const iconColorVariants = iconData.colors | ||||
| 	const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", { | ||||
| @@ -146,44 +144,13 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// Handlers for main preview icon | ||||
| 	const handlePreviewLoadingComplete = () => { | ||||
| 		setIsPreviewLoading(false) | ||||
| 		setHasPreviewError(false) | ||||
| 	} | ||||
|  | ||||
| 	const handlePreviewError = () => { | ||||
| 		setIsPreviewLoading(false) | ||||
| 		setHasPreviewError(true) | ||||
| 	} | ||||
|  | ||||
| 	// URLs for main preview icon | ||||
| 	const previewWebpSrc = `${BASE_URL}/webp/${icon}.webp` | ||||
| 	const previewOriginalSrc = `${BASE_URL}/${iconData.base}/${icon}.${iconData.base}` | ||||
| 	const previewOriginalFormat = iconData.base | ||||
|  | ||||
| 	const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => { | ||||
| 		const [isLoading, setIsLoading] = useState(true) | ||||
| 		const [hasError, setHasError] = useState(false) | ||||
|  | ||||
| 		const variantName = theme && iconColorVariants?.[theme] ? iconColorVariants[theme] : iconName | ||||
| 		const originalFormat = iconData.base | ||||
| 		const originalImageUrl = `${BASE_URL}/${originalFormat}/${variantName}.${originalFormat}` | ||||
| 		const webpImageUrl = `${BASE_URL}/webp/${variantName}.webp` | ||||
| 		const githubUrl = `${REPO_PATH}/tree/main/${originalFormat}/${iconName}.${originalFormat}` | ||||
| 		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 | ||||
|  | ||||
| 		const handleLoadingComplete = () => { | ||||
| 			setIsLoading(false) | ||||
| 			setHasError(false) | ||||
| 		} | ||||
|  | ||||
| 		const handleError = () => { | ||||
| 			setIsLoading(false) | ||||
| 			setHasError(true) | ||||
| 		} | ||||
|  | ||||
| 		return ( | ||||
| 			<TooltipProvider key={variantKey} delayDuration={500}> | ||||
| 				<MagicCard className="p-0 rounded-md"> | ||||
| @@ -191,65 +158,51 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail | ||||
| 						<Tooltip> | ||||
| 							<TooltipTrigger asChild> | ||||
| 								<motion.div | ||||
| 									className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group flex items-center justify-center" | ||||
| 									whileHover={{ scale: hasError ? 1 : 1.05 }} | ||||
| 									whileTap={{ scale: hasError ? 1 : 0.95 }} | ||||
| 									onClick={(e) => !hasError && handleCopy(format === 'webp' ? webpImageUrl : originalImageUrl, variantKey, e)} | ||||
| 									aria-label={hasError ? "Image failed to load" : `Copy ${format.toUpperCase()} URL for ${iconName}${theme ? ` (${theme} theme)` : ""}`} | ||||
| 									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)` : ""}`} | ||||
| 								> | ||||
| 									{isLoading && !hasError && ( | ||||
| 										<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded-xl z-10" /> | ||||
| 									)} | ||||
| 									{hasError ? ( | ||||
| 										<AlertTriangle className="h-12 w-12 text-red-500 z-10 cursor-help" /> | ||||
| 									) : ( | ||||
| 										<> | ||||
| 											<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors pointer-events-none" /> | ||||
| 											<motion.div | ||||
| 												className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl pointer-events-none" | ||||
| 												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> | ||||
| 									<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" /> | ||||
|  | ||||
| 											<picture> | ||||
| 												<source srcSet={webpImageUrl} type="image/webp" /> | ||||
| 												<source srcSet={originalImageUrl} type={`image/${originalFormat === 'svg' ? 'svg+xml' : originalFormat}`} /> | ||||
| 												<Image | ||||
| 													src={originalImageUrl} | ||||
| 													alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`} | ||||
| 													fill | ||||
| 													className={`object-contain p-4 transition-opacity duration-500 ${isLoading || hasError ? 'opacity-0' : 'opacity-100'}`} | ||||
| 													onLoadingComplete={handleLoadingComplete} | ||||
| 													onError={handleError} | ||||
| 												/> | ||||
| 											</picture> | ||||
| 										</> | ||||
| 									)} | ||||
| 									<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> | ||||
| 									{hasError | ||||
| 										? "Image failed to load, likely due to size limits. Please raise an issue on GitHub." | ||||
| 										: isCopied | ||||
| 											? "URL Copied!" | ||||
| 											: "Click to copy direct URL to clipboard"} | ||||
| 								</p> | ||||
| 								<p>Click to copy direct URL to clipboard</p> | ||||
| 							</TooltipContent> | ||||
| 						</Tooltip> | ||||
|  | ||||
| 						<p className="text-sm font-medium capitalize"> | ||||
| 							{format.toUpperCase()} {theme && `(${theme})`} | ||||
| 						</p> | ||||
| 						<p className="text-sm font-medium">{format.toUpperCase()}</p> | ||||
|  | ||||
| 						<div className="flex gap-2 mt-3 w-full justify-center"> | ||||
| 							<Tooltip> | ||||
| @@ -258,15 +211,14 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail | ||||
| 										variant="outline" | ||||
| 										size="icon" | ||||
| 										className="h-8 w-8 rounded-lg cursor-pointer" | ||||
| 										onClick={(e) => !hasError && handleDownload(e, format === 'webp' ? webpImageUrl : originalImageUrl, `${variantName}.${format}`)} | ||||
| 										onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)} | ||||
| 										aria-label={`Download ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`} | ||||
| 										disabled={hasError} | ||||
| 									> | ||||
| 										<Download className="w-4 h-4" /> | ||||
| 									</Button> | ||||
| 								</TooltipTrigger> | ||||
| 								<TooltipContent> | ||||
| 									<p>{hasError ? "Download unavailable" : "Download icon file"}</p> | ||||
| 									<p>Download icon file</p> | ||||
| 								</TooltipContent> | ||||
| 							</Tooltip> | ||||
|  | ||||
| @@ -276,26 +228,30 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail | ||||
| 										variant="outline" | ||||
| 										size="icon" | ||||
| 										className="h-8 w-8 rounded-lg cursor-pointer" | ||||
| 										onClick={(e) => !hasError && handleCopy(format === 'webp' ? webpImageUrl : originalImageUrl, `btn-${variantKey}`, e)} | ||||
| 										onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)} | ||||
| 										aria-label={`Copy URL for ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`} | ||||
| 										disabled={hasError} | ||||
| 									> | ||||
| 										{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />} | ||||
| 									</Button> | ||||
| 								</TooltipTrigger> | ||||
| 								<TooltipContent> | ||||
| 									<p>{hasError ? "Copy unavailable" : isCopied ? "URL Copied!" : "Copy direct URL to clipboard"}</p> | ||||
| 									<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> | ||||
| 									<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} ${originalFormat} file on GitHub`} | ||||
| 											aria-label={`View ${iconName} ${format} file on GitHub`} | ||||
| 										> | ||||
| 											<Github className="w-4 h-4" /> | ||||
| 										</Link> | ||||
| @@ -312,6 +268,8 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail | ||||
| 		) | ||||
| 	} | ||||
|  | ||||
| 	const formatedIconName = formatIconName(icon) | ||||
|  | ||||
| 	return ( | ||||
| 		<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"> | ||||
| @@ -320,40 +278,18 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail | ||||
| 					<Card className="h-full bg-background/50 border shadow-lg"> | ||||
| 						<CardHeader className="pb-4"> | ||||
| 							<div className="flex flex-col items-center"> | ||||
| 								{/* Apply loading/error handling to the main preview icon */} | ||||
| 								<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3 mb-4"> | ||||
| 									{isPreviewLoading && !hasPreviewError && ( | ||||
| 										<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded-xl" /> | ||||
| 									)} | ||||
| 									{hasPreviewError ? ( | ||||
| 										<TooltipProvider delayDuration={300}> | ||||
| 											<Tooltip> | ||||
| 												<TooltipTrigger aria-label="Preview image loading error"> | ||||
| 													<AlertTriangle className="h-16 w-16 text-red-500 cursor-help" /> | ||||
| 												</TooltipTrigger> | ||||
| 												<TooltipContent side="bottom"> | ||||
| 													<p>Preview failed to load, likely due to size limits. Please raise an issue.</p> | ||||
| 												</TooltipContent> | ||||
| 											</Tooltip> | ||||
| 										</TooltipProvider> | ||||
| 									) : ( | ||||
| 										<picture> | ||||
| 											<source srcSet={previewWebpSrc} type="image/webp" /> | ||||
| 											<source srcSet={previewOriginalSrc} type={`image/${previewOriginalFormat === 'svg' ? 'svg+xml' : previewOriginalFormat}`} /> | ||||
| 											<Image | ||||
| 												src={previewOriginalSrc} | ||||
| 												alt={`High quality ${icon.replace(/-/g, " ")} icon preview`} | ||||
| 												fill // Use fill instead of width/height for parent relative sizing | ||||
| 												className={`object-contain transition-opacity duration-500 ${isPreviewLoading || hasPreviewError ? 'opacity-0' : 'opacity-100'}`} | ||||
| 												onLoadingComplete={handlePreviewLoadingComplete} | ||||
| 												onError={handlePreviewError} | ||||
| 												priority // Prioritize loading the main icon | ||||
| 											/> | ||||
| 										</picture> | ||||
| 									)} | ||||
| 								<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} | ||||
| 										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"> | ||||
| 									<h1>{icon.replace(/-/g, " ")}</h1> | ||||
| 									<h1>{formatedIconName}</h1> | ||||
| 								</CardTitle> | ||||
| 							</div> | ||||
| 						</CardHeader> | ||||
| @@ -433,16 +369,14 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail | ||||
| 									<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 | ||||
| 											Available in {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."} | ||||
| 										</p> | ||||
| 										<p> | ||||
| 											Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {icon.replace(/-/g, " ")}{" "} | ||||
| 											logo. | ||||
| 											Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {icon.replace(/-/g, " ")} logo. | ||||
| 										</p> | ||||
| 									</div> | ||||
| 								</div> | ||||
| @@ -548,63 +482,31 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail | ||||
| 					</Card> | ||||
| 				</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> | ||||
| 					) | ||||
| 				})()} | ||||
| 			{iconData.categories && iconData.categories.length > 0 && ( | ||||
| 				<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 {iconData.categories.map((cat) => cat.replace(/-/g, " ")).join(", ")} categories | ||||
| 							</CardDescription> | ||||
| 						</CardHeader> | ||||
| 						<CardContent> | ||||
| 							<IconsGrid | ||||
| 								filteredIcons={Object.entries(allIcons) | ||||
| 									.filter(([name, data]) => { | ||||
| 										if (name === icon) return false | ||||
| 										return data.categories?.some((cat) => iconData.categories?.includes(cat)) | ||||
| 									}) | ||||
| 									.map(([name, data]) => ({ name, data }))} | ||||
| 								matchedAliases={{}} | ||||
| 							/> | ||||
| 						</CardContent> | ||||
| 					</Card> | ||||
| 				</section> | ||||
| 			)} | ||||
| 		</main> | ||||
| 	) | ||||
| } | ||||
| } | ||||
| @@ -45,7 +45,7 @@ export function VirtualizedIconsGrid({ filteredIcons, matchedAliases }: IconsGri | ||||
| 	const rowVirtualizer = useWindowVirtualizer({ | ||||
| 		count: rowCount, | ||||
| 		estimateSize: () => 140, | ||||
| 		overscan: 5, | ||||
| 		overscan: 2, | ||||
| 	}) | ||||
|  | ||||
| 	return ( | ||||
|   | ||||
| @@ -4,7 +4,6 @@ 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 { MagicCard } from "@/components/magicui/magic-card" | ||||
| import { | ||||
| 	DropdownMenu, | ||||
| 	DropdownMenuCheckboxItem, | ||||
| @@ -18,13 +17,10 @@ import { | ||||
| } from "@/components/ui/dropdown-menu" | ||||
| import { Input } from "@/components/ui/input" | ||||
| import { Separator } from "@/components/ui/separator" | ||||
| import { BASE_URL } from "@/constants" | ||||
| import type { Icon, IconSearchProps } from "@/types/icons" | ||||
| 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 Image from "next/image" | ||||
| import Link from "next/link" | ||||
| import posthog from "posthog-js" | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react" | ||||
| import { toast } from "sonner" | ||||
| @@ -229,11 +225,11 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 	const getSortLabel = (sort: SortOption) => { | ||||
| 		switch (sort) { | ||||
| 			case "relevance": | ||||
| 				return "Relevance" | ||||
| 				return "Best match" | ||||
| 			case "alphabetical-asc": | ||||
| 				return "Name (A-Z)" | ||||
| 				return "A to Z" | ||||
| 			case "alphabetical-desc": | ||||
| 				return "Name (Z-A)" | ||||
| 				return "Z to A" | ||||
| 			case "newest": | ||||
| 				return "Newest first" | ||||
| 			default: | ||||
| @@ -266,7 +262,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 					</div> | ||||
| 					<Input | ||||
| 						type="search" | ||||
| 						placeholder="Search for icons..." | ||||
| 						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)} | ||||
| @@ -278,18 +274,18 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 					{/* Filter dropdown */} | ||||
| 					<DropdownMenu> | ||||
| 						<DropdownMenuTrigger asChild> | ||||
| 							<Button | ||||
| 								variant="outline" | ||||
| 								size="sm" | ||||
| 								className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm" | ||||
| 								aria-label="Filter icons" | ||||
| 							> | ||||
| 							<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>{selectedCategories.length > 0 ? `Filters (${selectedCategories.length})` : "Filter"}</span> | ||||
| 								<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> | ||||
| 							<DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel> | ||||
| 							<DropdownMenuSeparator /> | ||||
|  | ||||
| 							<div className="max-h-[40vh] overflow-y-auto p-1"> | ||||
| @@ -315,7 +311,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 										}} | ||||
| 										className="cursor-pointer  focus: focus:bg-rose-50 dark:focus:bg-rose-950/20" | ||||
| 									> | ||||
| 										Clear categories | ||||
| 										Clear all filters | ||||
| 									</DropdownMenuItem> | ||||
| 								</> | ||||
| 							)} | ||||
| @@ -331,20 +327,18 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 							</Button> | ||||
| 						</DropdownMenuTrigger> | ||||
| 						<DropdownMenuContent align="start" className="w-56"> | ||||
| 							<DropdownMenuLabel className="font-semibold">Sort Icons</DropdownMenuLabel> | ||||
| 							<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 | ||||
| 									Best match | ||||
| 								</DropdownMenuRadioItem> | ||||
| 								<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer"> | ||||
| 									<ArrowDownAZ className="h-4 w-4 mr-2" /> | ||||
| 									Name (A-Z) | ||||
| 									<ArrowDownAZ className="h-4 w-4 mr-2" />A to Z | ||||
| 								</DropdownMenuRadioItem> | ||||
| 								<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer"> | ||||
| 									<ArrowUpZA className="h-4 w-4 mr-2" /> | ||||
| 									Name (Z-A) | ||||
| 									<ArrowUpZA className="h-4 w-4 mr-2" />Z to A | ||||
| 								</DropdownMenuRadioItem> | ||||
| 								<DropdownMenuRadioItem value="newest" className="cursor-pointer"> | ||||
| 									<Calendar className="h-4 w-4 mr-2" /> | ||||
| @@ -356,15 +350,9 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
|  | ||||
| 					{/* 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" | ||||
| 							aria-label="Reset all filters" | ||||
| 						> | ||||
| 						<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</span> | ||||
| 							<span>Clear all</span> | ||||
| 						</Button> | ||||
| 					)} | ||||
| 				</div> | ||||
| @@ -372,7 +360,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 				{/* 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> | ||||
| 						<span className="text-sm text-muted-foreground">Filters:</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"> | ||||
| @@ -398,7 +386,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 							}} | ||||
| 							className="text-xs h-7 px-2 cursor-pointer" | ||||
| 						> | ||||
| 							Clear | ||||
| 							Clear all | ||||
| 						</Button> | ||||
| 					</div> | ||||
| 				)} | ||||
| @@ -409,33 +397,27 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 			{filteredIcons.length === 0 ? ( | ||||
| 				<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center"> | ||||
| 					<div className="text-center"> | ||||
| 						<h2 className="text-3xl sm:text-5xl font-semibold">Icon not found</h2> | ||||
| 						<p className="text-lg text-muted-foreground mt-2">Help us expand our collection</p> | ||||
| 					</div> | ||||
| 					<div className="flex flex-col gap-4 items-center w-full"> | ||||
| 						<IconSubmissionContent /> | ||||
| 						<div className="mt-4 flex items-center gap-2 justify-center"> | ||||
| 							<span className="text-sm text-muted-foreground">Can't submit it yourself?</span> | ||||
| 							<Button | ||||
| 								className="cursor-pointer" | ||||
| 								variant="outline" | ||||
| 								size="sm" | ||||
| 								onClick={() => { | ||||
| 									setIsLazyRequestSubmitted(true) | ||||
| 									toast("Request received!", { | ||||
| 										description: `We've noted your request for "${searchQuery || "this icon"}". Thanks for your suggestion.`, | ||||
| 									}) | ||||
| 									posthog.capture("lazy icon request", { | ||||
| 										query: searchQuery, | ||||
| 										categories: selectedCategories, | ||||
| 									}) | ||||
| 								}} | ||||
| 								disabled={isLazyRequestSubmitted} | ||||
| 							> | ||||
| 								Request this icon | ||||
| 							</Button> | ||||
| 						</div> | ||||
| 						<h2 className="text-3xl sm:text-5xl font-semibold">We don't have this one...yet!</h2> | ||||
| 					</div> | ||||
| 					<Button | ||||
| 						className="cursor-pointer motion-preset-pop" | ||||
| 						variant="default" | ||||
| 						size="lg" | ||||
| 						onClick={() => { | ||||
| 							setIsLazyRequestSubmitted(true) | ||||
| 							toast("We hear you!", { | ||||
| 								description: `Okay, okay... we'll consider adding "${searchQuery || "that icon"}" just for you. 😉`, | ||||
| 							}) | ||||
| 							posthog.capture("lazy icon request", { | ||||
| 								query: searchQuery, | ||||
| 								categories: selectedCategories, | ||||
| 							}) | ||||
| 						}} | ||||
| 						disabled={isLazyRequestSubmitted} | ||||
| 					> | ||||
| 						I want this icon added but I'm too lazy to add it myself | ||||
| 					</Button> | ||||
| 					<IconSubmissionContent /> | ||||
| 				</div> | ||||
| 			) : ( | ||||
| 				<> | ||||
| @@ -455,52 +437,4 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 			)} | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
|  | ||||
| function IconCard({ | ||||
| 	name, | ||||
| 	data: iconData, | ||||
| 	matchedAlias, | ||||
| }: { | ||||
| 	name: string | ||||
| 	data: Icon | ||||
| 	matchedAlias?: string | null | ||||
| }) { | ||||
| 	return ( | ||||
| 		<MagicCard className="rounded-md shadow-md"> | ||||
| 			<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer"> | ||||
| 				<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2"> | ||||
| 					<Image | ||||
| 						src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`} | ||||
| 						alt={`${name} icon`} | ||||
| 						fill | ||||
| 						className="object-contain p-1 group-hover:scale-110 transition-transform duration-300" | ||||
| 					/> | ||||
| 				</div> | ||||
| 				<span className="text-xs sm:text-sm text-center truncate w-full capitalize group-hover:text-rose-500 dark:group-hover:text-rose-400 transition-colors duration-200 font-medium"> | ||||
| 					{name.replace(/-/g, " ")} | ||||
| 				</span> | ||||
|  | ||||
| 				{matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>} | ||||
| 			</Link> | ||||
| 		</MagicCard> | ||||
| 	) | ||||
| } | ||||
|  | ||||
| interface IconsGridProps { | ||||
| 	filteredIcons: { name: string; data: Icon }[] | ||||
| 	matchedAliases: Record<string, string> | ||||
| } | ||||
|  | ||||
| function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) { | ||||
| 	return ( | ||||
| 		<> | ||||
| 			<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2"> | ||||
| 				{filteredIcons.slice(0, 120).map(({ name, data }) => ( | ||||
| 					<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name] || null} /> | ||||
| 				))} | ||||
| 			</div> | ||||
| 			{filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>} | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
| } | ||||
| @@ -2,14 +2,12 @@ | ||||
|  | ||||
| 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, AlertTriangle } from "lucide-react" | ||||
| import { ArrowRight, Clock, ExternalLink } from "lucide-react" | ||||
| import Image from "next/image" | ||||
| import Link from "next/link" | ||||
| import { useState } from "react" | ||||
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" | ||||
|  | ||||
| function formatIconDate(timestamp: string): string { | ||||
| 	const date = new Date(timestamp) | ||||
| @@ -32,7 +30,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) { | ||||
| 			{/* Background glow */} | ||||
| 			<div className="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true" /> | ||||
|  | ||||
| 			<div className="mx-auto px-4 sm:px-6 lg:px-8"> | ||||
| 			<div className="mx-auto px-6 lg:px-8"> | ||||
| 				<div className="mx-auto max-w-2xl text-center my-4"> | ||||
| 					<h2 className="text-3xl font-bold tracking-tight sm:text-4xl bg-clip-text text-transparent bg-gradient-to-r from-rose-600 to-rose-500  motion-safe:motion-preset-fade-lg motion-duration-500"> | ||||
| 						Recently Added Icons | ||||
| @@ -63,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 all icons | ||||
| 						View complete collection | ||||
| 						<ArrowRight className="w-4 h-4 ml-1.5 transition-transform duration-200 group-hover:translate-x-1" /> | ||||
| 					</Link> | ||||
| 				</div> | ||||
| @@ -80,24 +78,7 @@ function RecentIconCard({ | ||||
| 	name: string | ||||
| 	data: Icon | ||||
| }) { | ||||
| 	const [isLoading, setIsLoading] = useState(true) | ||||
| 	const [hasError, setHasError] = useState(false) | ||||
|  | ||||
| 	// Construct URLs | ||||
| 	const webpSrc = `${BASE_URL}/webp/${name}.webp` | ||||
| 	const originalSrc = `${BASE_URL}/${data.base}/${name}.${data.base}` | ||||
| 	const originalFormat = data.base | ||||
|  | ||||
| 	const handleLoadingComplete = () => { | ||||
| 		setIsLoading(false) | ||||
| 		setHasError(false) | ||||
| 	} | ||||
|  | ||||
| 	const handleError = () => { | ||||
| 		setIsLoading(false) | ||||
| 		setHasError(true) | ||||
| 	} | ||||
|  | ||||
| 	const formattedIconName = formatIconName(name) | ||||
| 	return ( | ||||
| 		<Link | ||||
| 			prefetch={false} | ||||
| @@ -105,47 +86,22 @@ 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 ${name.replace(/-/g, " ")} icon`} | ||||
| 			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 pointer-events-none" /> | ||||
| 			<div className="absolute inset-0 bg-gradient-to-br from-rose-500/5 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300" /> | ||||
|  | ||||
| 			{/* Image container with loading/error handling */} | ||||
| 			<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2 flex items-center justify-center"> | ||||
| 				{isLoading && !hasError && ( | ||||
| 					<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded" /> | ||||
| 				)} | ||||
| 				{hasError ? ( | ||||
| 					<TooltipProvider delayDuration={300}> | ||||
| 						<Tooltip> | ||||
| 							<TooltipTrigger aria-label="Image loading error"> | ||||
| 								<AlertTriangle className="h-6 w-6 sm:h-8 sm:w-8 text-red-500 cursor-help" /> | ||||
| 							</TooltipTrigger> | ||||
| 							<TooltipContent side="bottom"> | ||||
| 								<p>Image failed to load. Please raise an issue.</p> | ||||
| 							</TooltipContent> | ||||
| 						</Tooltip> | ||||
| 					</TooltipProvider> | ||||
| 				) : ( | ||||
| 					<picture> | ||||
| 						<source srcSet={webpSrc} type="image/webp" /> | ||||
| 						<source srcSet={originalSrc} type={`image/${originalFormat === 'svg' ? 'svg+xml' : originalFormat}`} /> | ||||
| 						<Image | ||||
| 							src={originalSrc} | ||||
| 							alt={`${name} icon`} | ||||
| 							fill | ||||
| 							className={`object-contain p-1 transition-opacity duration-500 group-hover:scale-110 ${isLoading || hasError ? 'opacity-0' : 'opacity-100'}`} | ||||
| 							onLoadingComplete={handleLoadingComplete} | ||||
| 							onError={handleError} | ||||
| 							// No priority needed for marquee items | ||||
| 						/> | ||||
| 					</picture> | ||||
| 				)} | ||||
| 			<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2"> | ||||
| 				<Image | ||||
| 					src={`${BASE_URL}/${data.base}/${name}.${data.base}`} | ||||
| 					alt={`${name} icon`} | ||||
| 					fill | ||||
| 					className="object-contain p-1 hover:scale-110 transition-transform duration-300" | ||||
| 				/> | ||||
| 			</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, " ")} | ||||
| 			<span className="text-xs sm:text-sm text-center truncate w-full capitalize  dark:hover:text-rose-400 transition-colors duration-200 font-medium"> | ||||
| 				{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"> | ||||
| @@ -154,9 +110,9 @@ function RecentIconCard({ | ||||
| 				</span> | ||||
| 			</div> | ||||
|  | ||||
| 			<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"> | ||||
| 				<ExternalLink className="w-3 h-3 text-muted-foreground" /> | ||||
| 			<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> | ||||
| 	) | ||||
| } | ||||
| } | ||||
							
								
								
									
										177
									
								
								web/src/components/ui/command.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								web/src/components/ui/command.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | ||||
| "use client" | ||||
|  | ||||
| import * as React from "react" | ||||
| import { Command as CommandPrimitive } from "cmdk" | ||||
| import { SearchIcon } from "lucide-react" | ||||
|  | ||||
| import { cn } from "@/lib/utils" | ||||
| import { | ||||
|   Dialog, | ||||
|   DialogContent, | ||||
|   DialogDescription, | ||||
|   DialogHeader, | ||||
|   DialogTitle, | ||||
| } from "@/components/ui/dialog" | ||||
|  | ||||
| function Command({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive>) { | ||||
|   return ( | ||||
|     <CommandPrimitive | ||||
|       data-slot="command" | ||||
|       className={cn( | ||||
|         "bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandDialog({ | ||||
|   title = "Command Palette", | ||||
|   description = "Search for a command to run...", | ||||
|   children, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof Dialog> & { | ||||
|   title?: string | ||||
|   description?: string | ||||
| }) { | ||||
|   return ( | ||||
|     <Dialog {...props}> | ||||
|       <DialogHeader className="sr-only"> | ||||
|         <DialogTitle>{title}</DialogTitle> | ||||
|         <DialogDescription>{description}</DialogDescription> | ||||
|       </DialogHeader> | ||||
|       <DialogContent className="overflow-hidden p-0"> | ||||
|         <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> | ||||
|       </DialogContent> | ||||
|     </Dialog> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandInput({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Input>) { | ||||
|   return ( | ||||
|     <div | ||||
|       data-slot="command-input-wrapper" | ||||
|       className="flex h-9 items-center gap-2 border-b px-3" | ||||
|     > | ||||
|       <SearchIcon className="size-4 shrink-0 opacity-50" /> | ||||
|       <CommandPrimitive.Input | ||||
|         data-slot="command-input" | ||||
|         className={cn( | ||||
|           "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50", | ||||
|           className | ||||
|         )} | ||||
|         {...props} | ||||
|       /> | ||||
|     </div> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandList({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.List>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.List | ||||
|       data-slot="command-list" | ||||
|       className={cn( | ||||
|         "max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandEmpty({ | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Empty>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Empty | ||||
|       data-slot="command-empty" | ||||
|       className="py-6 text-center text-sm" | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandGroup({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Group>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Group | ||||
|       data-slot="command-group" | ||||
|       className={cn( | ||||
|         "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandSeparator({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Separator>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Separator | ||||
|       data-slot="command-separator" | ||||
|       className={cn("bg-border -mx-1 h-px", className)} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandItem({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<typeof CommandPrimitive.Item>) { | ||||
|   return ( | ||||
|     <CommandPrimitive.Item | ||||
|       data-slot="command-item" | ||||
|       className={cn( | ||||
|         "data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| function CommandShortcut({ | ||||
|   className, | ||||
|   ...props | ||||
| }: React.ComponentProps<"span">) { | ||||
|   return ( | ||||
|     <span | ||||
|       data-slot="command-shortcut" | ||||
|       className={cn( | ||||
|         "text-muted-foreground ml-auto text-xs tracking-widest", | ||||
|         className | ||||
|       )} | ||||
|       {...props} | ||||
|     /> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| export { | ||||
|   Command, | ||||
|   CommandDialog, | ||||
|   CommandInput, | ||||
|   CommandList, | ||||
|   CommandEmpty, | ||||
|   CommandGroup, | ||||
|   CommandItem, | ||||
|   CommandShortcut, | ||||
|   CommandSeparator, | ||||
| } | ||||
							
								
								
									
										25
									
								
								web/src/hooks/use-media-query.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								web/src/hooks/use-media-query.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| "use client" | ||||
|  | ||||
| import { useEffect, useState } from "react" | ||||
|  | ||||
| export function useMediaQuery(query: string): boolean { | ||||
| 	const [matches, setMatches] = useState(false) | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const media = window.matchMedia(query) | ||||
|  | ||||
| 		// Initial check | ||||
| 		if (media.matches !== matches) { | ||||
| 			setMatches(media.matches) | ||||
| 		} | ||||
|  | ||||
| 		// Setup listener for changes | ||||
| 		const listener = () => setMatches(media.matches) | ||||
| 		media.addEventListener("change", listener) | ||||
|  | ||||
| 		// Cleanup | ||||
| 		return () => media.removeEventListener("change", listener) | ||||
| 	}, [query, matches]) | ||||
|  | ||||
| 	return matches | ||||
| } | ||||
| @@ -5,6 +5,10 @@ export function cn(...inputs: ClassValue[]) { | ||||
| 	return twMerge(clsx(inputs)) | ||||
| } | ||||
|  | ||||
| export function formatIconName(name: string) { | ||||
| 	return name.replace(/-/g, " ") | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Calculate Levenshtein distance between two strings | ||||
|  */ | ||||
|   | ||||
		Reference in New Issue
	
	Block a user