mirror of
https://github.com/homarr-labs/dashboard-icons.git
synced 2025-06-17 18:23:40 +02:00
revert: revert changes
This commit is contained in:
parent
9d2a35489f
commit
d0f8f8ced9
17 changed files with 624 additions and 500 deletions
|
@ -42,6 +42,7 @@
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.7.3",
|
"framer-motion": "^12.7.3",
|
||||||
|
|
21
web/pnpm-lock.yaml
generated
21
web/pnpm-lock.yaml
generated
|
@ -101,6 +101,9 @@ importers:
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
@ -1549,6 +1552,12 @@ packages:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
|
@ -3424,6 +3433,18 @@ snapshots:
|
||||||
|
|
||||||
clsx@2.1.1: {}
|
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:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
|
|
|
@ -1,13 +1,22 @@
|
||||||
import { IconDetails } from "@/components/icon-details"
|
import { IconDetails } from "@/components/icon-details"
|
||||||
import { BASE_URL, WEB_URL } from "@/constants"
|
import { BASE_URL, WEB_URL } from "@/constants"
|
||||||
import { getAllIcons, getAuthorData } from "@/lib/api"
|
import { getAllIcons, getAuthorData } from "@/lib/api"
|
||||||
|
import { formatIconName } from "@/lib/utils"
|
||||||
import type { Metadata, ResolvingMetadata } from "next"
|
import type { Metadata, ResolvingMetadata } from "next"
|
||||||
|
import { default as dynamicImport } from "next/dynamic"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
export const dynamicParams = false
|
export const dynamicParams = false
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
const iconsData = await getAllIcons()
|
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) => ({
|
return Object.keys(iconsData).map((icon) => ({
|
||||||
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()}`)
|
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 pageUrl = `${WEB_URL}/icons/${icon}`
|
||||||
const formattedIconName = icon
|
const formattedIconName = icon
|
||||||
.split("-")
|
.split("-")
|
||||||
|
@ -61,7 +69,7 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
||||||
"app directory",
|
"app directory",
|
||||||
],
|
],
|
||||||
icons: {
|
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.`,
|
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: {
|
openGraph: {
|
||||||
|
@ -74,13 +82,11 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
||||||
modifiedTime: updateDate.toISOString(),
|
modifiedTime: updateDate.toISOString(),
|
||||||
section: "Icons",
|
section: "Icons",
|
||||||
tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"],
|
tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"],
|
||||||
images: [iconPreviewImageUrl],
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: `${formattedIconName} Icon | Dashboard Icons`,
|
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.`,
|
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: {
|
alternates: {
|
||||||
canonical: pageUrl,
|
canonical: pageUrl,
|
||||||
|
|
|
@ -10,28 +10,28 @@ export const size = {
|
||||||
|
|
||||||
// Define a fixed list of representative icons
|
// Define a fixed list of representative icons
|
||||||
const representativeIcons = [
|
const representativeIcons = [
|
||||||
"github",
|
"homarr",
|
||||||
"discord",
|
"sonarr",
|
||||||
"slack",
|
"radarr",
|
||||||
"docker",
|
"lidarr",
|
||||||
"kubernetes",
|
"readarr",
|
||||||
"grafana",
|
"prowlarr",
|
||||||
"prometheus",
|
"qbittorrent",
|
||||||
"nextcloud",
|
"home-assistant",
|
||||||
"homeassistant",
|
|
||||||
"cloudflare",
|
"cloudflare",
|
||||||
"nginx",
|
"github",
|
||||||
"traefik",
|
"traefik",
|
||||||
"portainer",
|
"portainer",
|
||||||
"plex",
|
"plex",
|
||||||
"jellyfin",
|
"jellyfin",
|
||||||
|
"overseerr",
|
||||||
]
|
]
|
||||||
|
|
||||||
export default async function Image() {
|
export default async function Image() {
|
||||||
const iconsData = await getAllIcons()
|
const iconsData = await getAllIcons()
|
||||||
const totalIcons = Object.keys(iconsData).length
|
const totalIcons = Object.keys(iconsData).length
|
||||||
// Round down to the nearest 100
|
// 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) => ({
|
const iconImages = representativeIcons.map((icon) => ({
|
||||||
name: 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.`,
|
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",
|
type: "website",
|
||||||
url: `${BASE_URL}/icons`,
|
url: `${BASE_URL}/icons`,
|
||||||
images: [
|
|
||||||
{
|
|
||||||
url: "/og-image.png",
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: "Browse Dashboard Icons Collection",
|
|
||||||
type: "image/png",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "Browse Icons | Free Dashboard Icons",
|
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.`,
|
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: {
|
alternates: {
|
||||||
canonical: `${BASE_URL}/icons`,
|
canonical: `${BASE_URL}/icons`,
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { PostHogProvider } from "@/components/PostHogProvider"
|
||||||
import { Footer } from "@/components/footer"
|
import { Footer } from "@/components/footer"
|
||||||
import { HeaderWrapper } from "@/components/header-wrapper"
|
import { HeaderWrapper } from "@/components/header-wrapper"
|
||||||
import { LicenseNotice } from "@/components/license-notice"
|
import { LicenseNotice } from "@/components/license-notice"
|
||||||
|
import { BASE_URL, WEB_URL, getDescription, websiteTitle } from "@/constants"
|
||||||
import { getTotalIcons } from "@/lib/api"
|
import { getTotalIcons } from "@/lib/api"
|
||||||
import type { Metadata, Viewport } from "next"
|
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, WEB_URL, getDescription, websiteTitle } from "@/constants"
|
|
||||||
import { ThemeProvider } from "./theme-provider"
|
import { ThemeProvider } from "./theme-provider"
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
|
@ -82,6 +82,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
|
<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 { IconSubmissionForm } from "@/components/icon-submission-form"
|
||||||
import { ThemeSwitcher } from "@/components/theme-switcher"
|
import { ThemeSwitcher } from "@/components/theme-switcher"
|
||||||
import { REPO_PATH } from "@/constants"
|
import { REPO_PATH } from "@/constants"
|
||||||
import { motion } from "framer-motion"
|
import { getIconsArray } from "@/lib/api"
|
||||||
import { Github } from "lucide-react"
|
import type { IconWithName } from "@/types/icons"
|
||||||
|
import { Github, Search } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { CommandMenu } from "./command-menu"
|
||||||
import { HeaderNav } from "./header-nav"
|
import { HeaderNav } from "./header-nav"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
|
||||||
|
|
||||||
export function Header() {
|
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 (
|
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="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">
|
<div className="flex items-center gap-2 md:gap-6">
|
||||||
<Link href="/" className="text-lg md:text-xl font-bold group hidden md:block">
|
<Link href="/" className="text-lg md:text-xl font-bold group hidden md:block">
|
||||||
|
@ -23,6 +52,30 @@ export function Header() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-4">
|
<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">
|
<div className="hidden md:flex items-center gap-2 md:gap-4">
|
||||||
<IconSubmissionForm />
|
<IconSubmissionForm />
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
@ -49,6 +102,9 @@ export function Header() {
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<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 ">
|
<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 ">
|
<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
|
Your definitive source for
|
||||||
<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" />
|
||||||
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>
|
|
||||||
<br />
|
<br />
|
||||||
<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" />
|
||||||
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>
|
|
||||||
<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText>
|
<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
|
@ -272,7 +224,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
|
||||||
<SearchInput searchQuery={searchQuery} setSearchQuery={setSearchQuery} totalIcons={totalIcons} />
|
<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">
|
<div className="w-full flex gap-3 md:gap-4 flex-wrap justify-center motion-preset-slide-down motion-duration-500">
|
||||||
<Link href="/icons">
|
<Link href="/icons">
|
||||||
<InteractiveHoverButton className="rounded-md bg-input/30">Browse icons</InteractiveHoverButton>
|
<InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton>
|
||||||
</Link>
|
</Link>
|
||||||
<GiveUsAStarButton stars={stars} />
|
<GiveUsAStarButton stars={stars} />
|
||||||
<GiveUsMoneyButton />
|
<GiveUsMoneyButton />
|
||||||
|
@ -526,7 +478,7 @@ function SearchInput({ searchQuery, setSearchQuery, totalIcons }: SearchInputPro
|
||||||
name="q"
|
name="q"
|
||||||
autoFocus
|
autoFocus
|
||||||
type="search"
|
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"
|
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}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import { MagicCard } from "@/components/magicui/magic-card"
|
import { MagicCard } from "@/components/magicui/magic-card"
|
||||||
import { BASE_URL } from "@/constants"
|
import { BASE_URL } from "@/constants"
|
||||||
|
import { formatIconName } from "@/lib/utils"
|
||||||
import type { Icon } from "@/types/icons"
|
import type { Icon } from "@/types/icons"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
import { preload } from "react-dom"
|
||||||
import { AlertTriangle } from "lucide-react"
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
export function IconCard({
|
export function IconCard({
|
||||||
name,
|
name,
|
||||||
|
@ -16,58 +15,20 @@ export function IconCard({
|
||||||
data: Icon
|
data: Icon
|
||||||
matchedAlias?: string
|
matchedAlias?: string
|
||||||
}) {
|
}) {
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const formatedIconName = formatIconName(name)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MagicCard className="rounded-md shadow-md">
|
<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">
|
<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">
|
<div className="relative h-16 w-16 mb-2">
|
||||||
{isLoading && !hasError && (
|
<Image
|
||||||
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded" />
|
src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`}
|
||||||
)}
|
alt={`${name} icon`}
|
||||||
{hasError ? (
|
fill
|
||||||
<TooltipProvider delayDuration={300}>
|
className="object-contain p-1 group-hover:scale-110 transition-transform duration-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>
|
</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">
|
<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>
|
</span>
|
||||||
|
|
||||||
{matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</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 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 { 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 Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
|
@ -17,6 +18,7 @@ import { toast } from "sonner"
|
||||||
import { Carbon } from "./carbon"
|
import { Carbon } from "./carbon"
|
||||||
import { MagicCard } from "./magicui/magic-card"
|
import { MagicCard } from "./magicui/magic-card"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
|
import { formatIconName } from "@/lib/utils"
|
||||||
|
|
||||||
export type IconDetailsProps = {
|
export type IconDetailsProps = {
|
||||||
icon: string
|
icon: string
|
||||||
|
@ -26,10 +28,6 @@ export type IconDetailsProps = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IconDetails({ icon, iconData, authorData, allIcons }: 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 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", {
|
||||||
|
@ -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 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 variantName = theme && iconColorVariants?.[theme] ? iconColorVariants[theme] : iconName
|
||||||
const originalFormat = iconData.base
|
const imageUrl = `${BASE_URL}/${format}/${variantName}.${format}`
|
||||||
const originalImageUrl = `${BASE_URL}/${originalFormat}/${variantName}.${originalFormat}`
|
const githubUrl = `${REPO_PATH}/tree/main/${format}/${iconName}.${format}`
|
||||||
const webpImageUrl = `${BASE_URL}/webp/${variantName}.webp`
|
|
||||||
const githubUrl = `${REPO_PATH}/tree/main/${originalFormat}/${iconName}.${originalFormat}`
|
|
||||||
const variantKey = `${format}-${theme || "default"}`
|
const variantKey = `${format}-${theme || "default"}`
|
||||||
const isCopied = copiedVariants[variantKey] || false
|
const isCopied = copiedVariants[variantKey] || false
|
||||||
|
|
||||||
const handleLoadingComplete = () => {
|
|
||||||
setIsLoading(false)
|
|
||||||
setHasError(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleError = () => {
|
|
||||||
setIsLoading(false)
|
|
||||||
setHasError(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider key={variantKey} delayDuration={500}>
|
<TooltipProvider key={variantKey} delayDuration={500}>
|
||||||
<MagicCard className="p-0 rounded-md">
|
<MagicCard className="p-0 rounded-md">
|
||||||
|
@ -191,65 +158,51 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group flex items-center justify-center"
|
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
|
||||||
whileHover={{ scale: hasError ? 1 : 1.05 }}
|
whileHover={{ scale: 1.05 }}
|
||||||
whileTap={{ scale: hasError ? 1 : 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={(e) => !hasError && handleCopy(format === 'webp' ? webpImageUrl : originalImageUrl, variantKey, e)}
|
onClick={(e) => handleCopy(imageUrl, variantKey, e)}
|
||||||
aria-label={hasError ? "Image failed to load" : `Copy ${format.toUpperCase()} URL for ${iconName}${theme ? ` (${theme} theme)` : ""}`}
|
aria-label={`Copy ${format.toUpperCase()} URL for ${iconName}${theme ? ` (${theme} theme)` : ""}`}
|
||||||
>
|
>
|
||||||
{isLoading && !hasError && (
|
<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 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>
|
|
||||||
|
|
||||||
<picture>
|
<motion.div
|
||||||
<source srcSet={webpImageUrl} type="image/webp" />
|
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
|
||||||
<source srcSet={originalImageUrl} type={`image/${originalFormat === 'svg' ? 'svg+xml' : originalFormat}`} />
|
initial={{ opacity: 0 }}
|
||||||
<Image
|
animate={{ opacity: isCopied ? 1 : 0 }}
|
||||||
src={originalImageUrl}
|
transition={{ duration: 0.2 }}
|
||||||
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
>
|
||||||
fill
|
<motion.div
|
||||||
className={`object-contain p-4 transition-opacity duration-500 ${isLoading || hasError ? 'opacity-0' : 'opacity-100'}`}
|
initial={{ scale: 0.5, opacity: 0 }}
|
||||||
onLoadingComplete={handleLoadingComplete}
|
animate={{
|
||||||
onError={handleError}
|
scale: isCopied ? 1 : 0.5,
|
||||||
/>
|
opacity: isCopied ? 1 : 0,
|
||||||
</picture>
|
}}
|
||||||
</>
|
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>
|
</motion.div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>Click to copy direct URL to clipboard</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>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<p className="text-sm font-medium capitalize">
|
<p className="text-sm font-medium">{format.toUpperCase()}</p>
|
||||||
{format.toUpperCase()} {theme && `(${theme})`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mt-3 w-full justify-center">
|
<div className="flex gap-2 mt-3 w-full justify-center">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
@ -258,15 +211,14 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-lg cursor-pointer"
|
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)` : ""}`}
|
aria-label={`Download ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
|
||||||
disabled={hasError}
|
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{hasError ? "Download unavailable" : "Download icon file"}</p>
|
<p>Download icon file</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
@ -276,26 +228,30 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-lg cursor-pointer"
|
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)` : ""}`}
|
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" />}
|
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{hasError ? "Copy unavailable" : isCopied ? "URL Copied!" : "Copy direct URL to clipboard"}</p>
|
<p>Copy direct URL to clipboard</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<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
|
<Link
|
||||||
href={githubUrl}
|
href={githubUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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" />
|
<Github className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -312,6 +268,8 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatedIconName = formatIconName(icon)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main 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">
|
||||||
|
@ -320,40 +278,18 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
||||||
<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">
|
||||||
{/* 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">
|
||||||
<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3 mb-4">
|
<Image
|
||||||
{isPreviewLoading && !hasPreviewError && (
|
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
|
||||||
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded-xl" />
|
width={96}
|
||||||
)}
|
height={96}
|
||||||
{hasPreviewError ? (
|
placeholder="empty"
|
||||||
<TooltipProvider delayDuration={300}>
|
alt={`High quality ${formatedIconName} icon in ${iconData.base.toUpperCase()} format`}
|
||||||
<Tooltip>
|
className="w-full h-full object-contain"
|
||||||
<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>
|
</div>
|
||||||
<CardTitle className="text-2xl font-bold capitalize text-center mb-2">
|
<CardTitle className="text-2xl font-bold capitalize text-center mb-2">
|
||||||
<h1>{icon.replace(/-/g, " ")}</h1>
|
<h1>{formatedIconName}</h1>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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>
|
<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">
|
<div className="text-xs text-muted-foreground space-y-2">
|
||||||
<p>
|
<p>
|
||||||
Available in{" "}
|
Available in {availableFormats.length > 1
|
||||||
{availableFormats.length > 1
|
|
||||||
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")}) `
|
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")}) `
|
||||||
: `${availableFormats[0].toUpperCase()} format `}
|
: `${availableFormats[0].toUpperCase()} format `}
|
||||||
with a base format of {iconData.base.toUpperCase()}.
|
with a base format of {iconData.base.toUpperCase()}.
|
||||||
{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."}
|
{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {icon.replace(/-/g, " ")}{" "}
|
Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {icon.replace(/-/g, " ")} logo.
|
||||||
logo.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -548,63 +482,31 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{iconData.categories &&
|
{iconData.categories && iconData.categories.length > 0 && (
|
||||||
iconData.categories.length > 0 &&
|
<section className="container mx-auto mt-12" aria-labelledby="related-icons-title">
|
||||||
(() => {
|
<Card className="bg-background/50 border shadow-lg">
|
||||||
const MAX_RELATED_ICONS = 16
|
<CardHeader>
|
||||||
const currentCategories = iconData.categories || []
|
<CardTitle>
|
||||||
|
<h2 id="related-icons-title">Related Icons</h2>
|
||||||
const relatedIconsWithScore = Object.entries(allIcons)
|
</CardTitle>
|
||||||
.map(([name, data]) => {
|
<CardDescription>
|
||||||
if (name === icon) return null // Exclude the current icon
|
Other icons from {iconData.categories.map((cat) => cat.replace(/-/g, " ")).join(", ")} categories
|
||||||
|
</CardDescription>
|
||||||
const otherCategories = data.categories || []
|
</CardHeader>
|
||||||
const commonCategories = currentCategories.filter((cat) => otherCategories.includes(cat))
|
<CardContent>
|
||||||
const score = commonCategories.length
|
<IconsGrid
|
||||||
|
filteredIcons={Object.entries(allIcons)
|
||||||
return score > 0 ? { name, data, score } : null
|
.filter(([name, data]) => {
|
||||||
})
|
if (name === icon) return false
|
||||||
.filter((item): item is { name: string; data: Icon; score: number } => item !== null) // Type guard
|
return data.categories?.some((cat) => iconData.categories?.includes(cat))
|
||||||
.sort((a, b) => b.score - a.score) // Sort by score DESC
|
})
|
||||||
|
.map(([name, data]) => ({ name, data }))}
|
||||||
const topRelatedIcons = relatedIconsWithScore.slice(0, MAX_RELATED_ICONS)
|
matchedAliases={{}}
|
||||||
|
/>
|
||||||
const viewMoreUrl = `/icons?${currentCategories.map((cat) => `category=${encodeURIComponent(cat)}`).join("&")}`
|
</CardContent>
|
||||||
|
</Card>
|
||||||
if (topRelatedIcons.length === 0) return null
|
</section>
|
||||||
|
)}
|
||||||
return (
|
|
||||||
<section className="container mx-auto mt-12" aria-labelledby="related-icons-title">
|
|
||||||
<Card className="bg-background/50 border shadow-lg">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
<h2 id="related-icons-title">Related Icons</h2>
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Other icons from {currentCategories.map((cat) => cat.replace(/-/g, " ")).join(", ")} categories
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<IconsGrid filteredIcons={topRelatedIcons} matchedAliases={{}} />
|
|
||||||
{relatedIconsWithScore.length > MAX_RELATED_ICONS && (
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="link"
|
|
||||||
className="text-muted-foreground hover:text-primary transition-colors duration-200 hover:no-underline"
|
|
||||||
>
|
|
||||||
<Link href={viewMoreUrl} className="no-underline">
|
|
||||||
View all related icons
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -45,7 +45,7 @@ export function VirtualizedIconsGrid({ filteredIcons, matchedAliases }: IconsGri
|
||||||
const rowVirtualizer = useWindowVirtualizer({
|
const rowVirtualizer = useWindowVirtualizer({
|
||||||
count: rowCount,
|
count: rowCount,
|
||||||
estimateSize: () => 140,
|
estimateSize: () => 140,
|
||||||
overscan: 5,
|
overscan: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { VirtualizedIconsGrid } from "@/components/icon-grid"
|
||||||
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
||||||
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 { MagicCard } from "@/components/magicui/magic-card"
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
|
@ -18,13 +17,10 @@ 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 { usePathname, useRouter, useSearchParams } from "next/navigation"
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
import Image from "next/image"
|
|
||||||
import Link from "next/link"
|
|
||||||
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"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
@ -229,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:
|
||||||
|
@ -266,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)}
|
||||||
|
@ -278,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">
|
||||||
|
@ -315,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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -331,20 +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" />
|
<ArrowDownAZ className="h-4 w-4 mr-2" />A to Z
|
||||||
Name (A-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" />
|
<ArrowUpZA className="h-4 w-4 mr-2" />Z to A
|
||||||
Name (Z-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" />
|
||||||
|
@ -356,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>
|
||||||
|
@ -372,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">
|
||||||
|
@ -398,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>
|
||||||
)}
|
)}
|
||||||
|
@ -409,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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
@ -456,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>}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -2,14 +2,12 @@
|
||||||
|
|
||||||
import { Marquee } from "@/components/magicui/marquee"
|
import { Marquee } from "@/components/magicui/marquee"
|
||||||
import { BASE_URL } from "@/constants"
|
import { BASE_URL } from "@/constants"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn, formatIconName } from "@/lib/utils"
|
||||||
import type { Icon, IconWithName } from "@/types/icons"
|
import type { Icon, IconWithName } from "@/types/icons"
|
||||||
import { format, isToday, isYesterday } from "date-fns"
|
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 Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
function formatIconDate(timestamp: string): string {
|
function formatIconDate(timestamp: string): string {
|
||||||
const date = new Date(timestamp)
|
const date = new Date(timestamp)
|
||||||
|
@ -32,7 +30,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
|
||||||
{/* Background glow */}
|
{/* 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="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">
|
<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">
|
<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
|
Recently Added Icons
|
||||||
|
@ -63,7 +61,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
|
||||||
href="/icons"
|
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"
|
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" />
|
<ArrowRight className="w-4 h-4 ml-1.5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
@ -80,24 +78,7 @@ function RecentIconCard({
|
||||||
name: string
|
name: string
|
||||||
data: Icon
|
data: Icon
|
||||||
}) {
|
}) {
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const formattedIconName = formatIconName(name)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
prefetch={false}
|
prefetch={false}
|
||||||
|
@ -105,47 +86,22 @@ function RecentIconCard({
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center p-3 sm:p-4 rounded-xl border border-border",
|
"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",
|
"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">
|
||||||
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2 flex items-center justify-center">
|
<Image
|
||||||
{isLoading && !hasError && (
|
src={`${BASE_URL}/${data.base}/${name}.${data.base}`}
|
||||||
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded" />
|
alt={`${name} icon`}
|
||||||
)}
|
fill
|
||||||
{hasError ? (
|
className="object-contain p-1 hover:scale-110 transition-transform duration-300"
|
||||||
<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>
|
</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">
|
||||||
<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}
|
||||||
{name.replace(/-/g, " ")}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center justify-center mt-2 w-full">
|
<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">
|
<span className="text-[10px] sm:text-xs text-muted-foreground flex items-center whitespace-nowrap hover:/70 transition-colors duration-200">
|
||||||
|
@ -154,8 +110,8 @@ function RecentIconCard({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
<div className="absolute top-2 right-2 opacity-0 group-hover/item:opacity-100 transition-opacity duration-200">
|
||||||
<ExternalLink className="w-3 h-3 text-muted-foreground" />
|
<ExternalLink className="w-3 h-3 " />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</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))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatIconName(name: string) {
|
||||||
|
return name.replace(/-/g, " ")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate Levenshtein distance between two strings
|
* Calculate Levenshtein distance between two strings
|
||||||
*/
|
*/
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue