mirror of
				https://github.com/walkxcode/dashboard-icons.git
				synced 2025-10-31 08:47:57 +01:00 
			
		
		
		
	feat(icons/id): Add related icons
This commit is contained in:
		
				
					committed by
					
						 Bjorn Lammers
						Bjorn Lammers
					
				
			
			
				
	
			
			
			
						parent
						
							f0e20c2b19
						
					
				
				
					commit
					64c49274da
				
			| @@ -99,5 +99,5 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str | ||||
|  | ||||
| 	const authorData = await getAuthorData(originalIconData.update.author.id) | ||||
|  | ||||
| 	return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} /> | ||||
| 	return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} allIcons={iconsData} /> | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { IconSearch } from "@/components/icon-search" | ||||
| import { BASE_URL } from "@/constants" | ||||
| import { getIconsArray } from "@/lib/api" | ||||
| import type { Metadata } from "next" | ||||
| import { IconSearch } from "./components/icon-search" | ||||
|  | ||||
| export async function generateMetadata(): Promise<Metadata> { | ||||
| 	const icons = await getIconsArray() | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import type { Metadata, Viewport } from "next" | ||||
| import { Inter } from "next/font/google" | ||||
| import { Toaster } from "sonner" | ||||
| import "./globals.css" | ||||
| import { BASE_URL, getDescription, WEB_URL, websiteTitle } from "@/constants" | ||||
| import { BASE_URL, WEB_URL, getDescription, websiteTitle } from "@/constants" | ||||
| import { ThemeProvider } from "./theme-provider" | ||||
|  | ||||
| const inter = Inter({ | ||||
|   | ||||
							
								
								
									
										35
									
								
								web/src/components/icon-card.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								web/src/components/icon-card.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import { MagicCard } from "@/components/magicui/magic-card" | ||||
| import { BASE_URL } from "@/constants" | ||||
| import type { Icon } from "@/types/icons" | ||||
| import Image from "next/image" | ||||
| import Link from "next/link" | ||||
|  | ||||
| export 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- dark:group-hover:text-primary 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> | ||||
| 	) | ||||
| } | ||||
| @@ -1,15 +1,15 @@ | ||||
| "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 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 } from "lucide-react" | ||||
| import { useTheme } from "next-themes" | ||||
| import Image from "next/image" | ||||
| import Link from "next/link" | ||||
| import { useCallback, useState } from "react" | ||||
| @@ -22,9 +22,10 @@ export type IconDetailsProps = { | ||||
| 	icon: string | ||||
| 	iconData: Icon | ||||
| 	authorData: AuthorData | ||||
| 	allIcons: IconFile | ||||
| } | ||||
|  | ||||
| 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 formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", { | ||||
| @@ -159,6 +160,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
| 									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" /> | ||||
|  | ||||
| @@ -264,23 +266,25 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
| 	} | ||||
|  | ||||
| 	return ( | ||||
| 		<div className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8"> | ||||
| 		<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"> | ||||
| 				{/* Left Column: Icon Info and Author */} | ||||
| 				<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} | ||||
| 										alt={`High quality ${icon.replace(/-/g, " ")} 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>{icon.replace(/-/g, " ")}</h1> | ||||
| 								</CardTitle> | ||||
| 							</div> | ||||
| 						</CardHeader> | ||||
| 						<CardContent> | ||||
| @@ -289,14 +293,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 ? ( | ||||
| @@ -379,7 +383,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> | ||||
| @@ -470,6 +476,31 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) { | ||||
| 					</Card> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</div> | ||||
| 			{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> | ||||
| 	) | ||||
| } | ||||
|   | ||||
							
								
								
									
										21
									
								
								web/src/components/icon-grid.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								web/src/components/icon-grid.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import type { Icon } from "@/types/icons" | ||||
|  | ||||
| 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] || null} /> | ||||
| 				))} | ||||
| 			</div> | ||||
| 			{filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>} | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
| @@ -1,7 +1,7 @@ | ||||
| "use client" | ||||
| 
 | ||||
| import { IconsGrid } from "@/components/icon-grid" | ||||
| import { IconSubmissionContent } from "@/components/icon-submission-form" | ||||
| import { MagicCard } from "@/components/magicui/magic-card" | ||||
| import { Badge } from "@/components/ui/badge" | ||||
| import { Button } from "@/components/ui/button" | ||||
| import { | ||||
| @@ -17,12 +17,9 @@ 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 Image from "next/image" | ||||
| import Link from "next/link" | ||||
| import { usePathname, useRouter, useSearchParams } from "next/navigation" | ||||
| import posthog from "posthog-js" | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react" | ||||
| @@ -228,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: | ||||
| @@ -265,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)} | ||||
| @@ -277,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"> | ||||
| @@ -314,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> | ||||
| 								</> | ||||
| 							)} | ||||
| @@ -330,18 +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" /> | ||||
| @@ -353,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> | ||||
| @@ -369,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"> | ||||
| @@ -395,7 +386,7 @@ export function IconSearch({ icons }: IconSearchProps) { | ||||
| 							}} | ||||
| 							className="text-xs h-7 px-2 cursor-pointer" | ||||
| 						> | ||||
| 							Clear | ||||
| 							Clear all | ||||
| 						</Button> | ||||
| 					</div> | ||||
| 				)} | ||||
| @@ -406,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> | ||||
| 			) : ( | ||||
| 				<> | ||||
| @@ -453,51 +438,3 @@ 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>} | ||||
| 		</> | ||||
| 	) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user