mirror of
https://github.com/homarr-labs/dashboard-icons.git
synced 2025-06-19 03:03:27 +02:00
feat(icons/id): Add related icons
This commit is contained in:
parent
f0e20c2b19
commit
64c49274da
7 changed files with 144 additions and 120 deletions
web/src
app
components
|
@ -99,5 +99,5 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str
|
||||||
|
|
||||||
const authorData = await getAuthorData(originalIconData.update.author.id)
|
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 { BASE_URL } from "@/constants"
|
||||||
import { getIconsArray } from "@/lib/api"
|
import { getIconsArray } from "@/lib/api"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { IconSearch } from "./components/icon-search"
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const icons = await getIconsArray()
|
const icons = await getIconsArray()
|
||||||
|
|
|
@ -7,7 +7,7 @@ import type { Metadata, Viewport } from "next"
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
import { Toaster } from "sonner"
|
import { Toaster } from "sonner"
|
||||||
import "./globals.css"
|
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"
|
import { ThemeProvider } from "./theme-provider"
|
||||||
|
|
||||||
const inter = Inter({
|
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"
|
"use client"
|
||||||
|
|
||||||
|
import { IconsGrid } from "@/components/icon-grid"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { BASE_URL, REPO_PATH } from "@/constants"
|
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 confetti from "canvas-confetti"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
|
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
|
@ -22,9 +22,10 @@ export type IconDetailsProps = {
|
||||||
icon: string
|
icon: string
|
||||||
iconData: Icon
|
iconData: Icon
|
||||||
authorData: AuthorData
|
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 authorName = authorData.name || authorData.login || ""
|
||||||
const iconColorVariants = iconData.colors
|
const iconColorVariants = iconData.colors
|
||||||
const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", {
|
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 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={(e) => handleCopy(imageUrl, variantKey, e)}
|
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" />
|
<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 (
|
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">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
{/* Left Column: Icon Info and Author */}
|
{/* Left Column: Icon Info and Author */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<Card className="h-full bg-background/50 border shadow-lg">
|
<Card className="h-full bg-background/50 border shadow-lg">
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<div className="flex flex-col items-center">
|
<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
|
<Image
|
||||||
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
|
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
|
||||||
width={96}
|
width={96}
|
||||||
height={96}
|
height={96}
|
||||||
alt={icon}
|
alt={`High quality ${icon.replace(/-/g, " ")} icon in ${iconData.base.toUpperCase()} format`}
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
@ -289,14 +293,14 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm font-medium">By:</p>
|
<p className="text-sm font-medium">By:</p>
|
||||||
<Avatar className="h-5 w-5 border">
|
<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>
|
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{authorData.html_url ? (
|
{authorData.html_url ? (
|
||||||
|
@ -379,7 +383,9 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card className="h-full bg-background/50 shadow-lg">
|
<Card className="h-full bg-background/50 shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Icon variants</CardTitle>
|
<CardTitle>
|
||||||
|
<h2>Icon variants</h2>
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription>
|
<CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
@ -470,6 +476,31 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
"use client"
|
||||||
|
|
||||||
|
import { IconsGrid } from "@/components/icon-grid"
|
||||||
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
||||||
import { MagicCard } from "@/components/magicui/magic-card"
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
|
@ -17,12 +17,9 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { BASE_URL } from "@/constants"
|
import type { IconSearchProps } from "@/types/icons"
|
||||||
import type { Icon, IconSearchProps } from "@/types/icons"
|
|
||||||
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
|
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import Image from "next/image"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import posthog from "posthog-js"
|
import posthog from "posthog-js"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
@ -228,11 +225,11 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||||
const getSortLabel = (sort: SortOption) => {
|
const getSortLabel = (sort: SortOption) => {
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case "relevance":
|
case "relevance":
|
||||||
return "Relevance"
|
return "Best match"
|
||||||
case "alphabetical-asc":
|
case "alphabetical-asc":
|
||||||
return "Name (A-Z)"
|
return "A to Z"
|
||||||
case "alphabetical-desc":
|
case "alphabetical-desc":
|
||||||
return "Name (Z-A)"
|
return "Z to A"
|
||||||
case "newest":
|
case "newest":
|
||||||
return "Newest first"
|
return "Newest first"
|
||||||
default:
|
default:
|
||||||
|
@ -265,7 +262,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
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"
|
className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
@ -277,18 +274,18 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||||
{/* Filter dropdown */}
|
{/* Filter dropdown */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm ">
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm"
|
|
||||||
aria-label="Filter icons"
|
|
||||||
>
|
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
<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>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-64 sm:w-56">
|
<DropdownMenuContent align="start" className="w-64 sm:w-56">
|
||||||
<DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel>
|
<DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<div className="max-h-[40vh] overflow-y-auto p-1">
|
<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"
|
className="cursor-pointer focus: focus:bg-rose-50 dark:focus:bg-rose-950/20"
|
||||||
>
|
>
|
||||||
Clear categories
|
Clear all filters
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -330,18 +327,18 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className="w-56">
|
<DropdownMenuContent align="start" className="w-56">
|
||||||
<DropdownMenuLabel className="font-semibold">Sort Icons</DropdownMenuLabel>
|
<DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
|
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
|
||||||
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
|
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
|
||||||
<Search className="h-4 w-4 mr-2" />
|
<Search className="h-4 w-4 mr-2" />
|
||||||
Relevance
|
Best match
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer">
|
<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>
|
||||||
<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer">
|
<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>
|
||||||
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
|
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
|
||||||
<Calendar className="h-4 w-4 mr-2" />
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
@ -353,15 +350,9 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||||
|
|
||||||
{/* Clear all button */}
|
{/* Clear all button */}
|
||||||
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
|
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={clearFilters} className="flex-1 sm:flex-none cursor-pointer bg-background">
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearFilters}
|
|
||||||
className="flex-1 sm:flex-none cursor-pointer bg-background"
|
|
||||||
aria-label="Reset all filters"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-2" />
|
<X className="h-4 w-4 mr-2" />
|
||||||
<span>Reset</span>
|
<span>Clear all</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -369,7 +360,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||||
{/* Active filter badges */}
|
{/* Active filter badges */}
|
||||||
{selectedCategories.length > 0 && (
|
{selectedCategories.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-2">
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedCategories.map((category) => (
|
{selectedCategories.map((category) => (
|
||||||
<Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
|
<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"
|
className="text-xs h-7 px-2 cursor-pointer"
|
||||||
>
|
>
|
||||||
Clear
|
Clear all
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -406,33 +397,27 @@ export function IconSearch({ icons }: IconSearchProps) {
|
||||||
{filteredIcons.length === 0 ? (
|
{filteredIcons.length === 0 ? (
|
||||||
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center">
|
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-3xl sm:text-5xl font-semibold">Icon not found</h2>
|
<h2 className="text-3xl sm:text-5xl font-semibold">We don't have this one...yet!</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>
|
|
||||||
</div>
|
</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>
|
</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>}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue