Merge branch 'main' into feat/wordmark-icons

This commit is contained in:
Thomas Camlong
2025-04-29 17:09:20 +02:00
269 changed files with 26106 additions and 25211 deletions

916
web/SEO.md Normal file
View File

@@ -0,0 +1,916 @@
# Dashboard Icons SEO Audit 2025
## Overview
This document presents a comprehensive SEO audit for the Dashboard Icons website built with Next.js 15.3. The audit analyzes current implementation and provides detailed recommendations based on the latest Next.js best practices for optimal search engine visibility and performance.
## Table of Contents
- [Current Implementation Assessment](#current-implementation-assessment)
- [Metadata Implementation](#metadata-implementation)
- [SEO Optimization Checklist](#seo-optimization-checklist)
- [Technical SEO](#technical-seo)
- [Performance Optimization](#performance-optimization)
- [Content and User Experience](#content-and-user-experience)
- [Mobile Optimization](#mobile-optimization)
- [Advanced Next.js 15.3 SEO Features](#advanced-nextjs-153-seo-features)
- [Recommendations](#recommendations)
- [Conclusion](#conclusion)
- [References](#references)
## Current Implementation Assessment
The Dashboard Icons project currently implements several good SEO practices:
- [x] Basic metadata configuration in layout.tsx and page.tsx files
- [x] Dynamic title and description generation with appropriate keyword inclusion
- [x] Open Graph tags for social sharing with proper image dimensions
- [x] Twitter Card metadata implementation for social visibility
- [x] Proper use of semantic HTML elements for content structure
- [x] Server-side rendering for improved indexing and crawler access
- [x] Canonical URLs properly configured across page types
- [x] Image optimization with next/image component for improved Core Web Vitals
However, there are several opportunities for improvement:
- [ ] No robots.txt implementation for directing crawler behavior
- [ ] Missing XML sitemap for improved content discovery
- [ ] No structured data (JSON-LD) for enhanced search results
- [ ] Limited use of advanced Next.js 15.3 metadata features
- [ ] Missing breadcrumb navigation for enhanced user experience and SEO
- [ ] No dynamic OG images for improved social sharing
## Metadata Implementation
The project uses Next.js App Router's built-in metadata API effectively across different page types:
### Root Layout Metadata Analysis
In `layout.tsx`, the site establishes global metadata that provides a solid foundation:
```typescript
// In layout.tsx
export async function generateMetadata(): Promise<Metadata> {
const { totalIcons } = await getTotalIcons()
return {
metadataBase: new URL(WEB_URL),
title: websiteTitle,
description: getDescription(totalIcons),
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
robots: {
index: true,
follow: true,
googleBot: "index, follow",
},
openGraph: {
siteName: WEB_URL,
title: websiteTitle,
url: BASE_URL,
description: getDescription(totalIcons),
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Dashboard Icons - Dashboard icons for self hosted services",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
title: WEB_URL,
description: getDescription(totalIcons),
images: ["/og-image.png"],
},
applicationName: WEB_URL,
alternates: {
canonical: BASE_URL,
},
// Additional configurations...
}
}
```
**Strengths:**
- Properly sets metadataBase for all relative URLs
- Includes comprehensive metadata for SEO and social sharing
- Dynamically generates description based on content (totalIcons)
- Properly configures robots directives
**Areas for improvement:**
- The `websiteTitle` ("Free Dashboard Icons - Download High-Quality UI & App Icons") could be more specific
- The OpenGraph URL points to BASE_URL (CDN) rather than WEB_URL (the actual site)
- Twitter title uses WEB_URL instead of an actual title
- Missing locale information for international SEO
### Page-Specific Metadata Analysis
For individual icon pages, metadata is comprehensively generated based on icon data:
```typescript
// In [icon]/page.tsx
export async function generateMetadata({ params, searchParams }: Props, parent: ResolvingMetadata): Promise<Metadata> {
const { icon } = await params
const iconsData = await getAllIcons()
// ...processing code...
const formattedIconName = icon
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
return {
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...`,
openGraph: {
title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE...`,
type: "article",
url: pageUrl,
authors: [authorName],
publishedTime: updateDate.toISOString(),
modifiedTime: updateDate.toISOString(),
section: "Icons",
tags: [formattedIconName, "dashboard icon", "service icon", ...],
},
twitter: {
card: "summary_large_image",
title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download the ${formattedIconName} icon...`,
images: [iconImageUrl],
},
alternates: {
canonical: pageUrl,
media: {
png: iconImageUrl,
svg: `${BASE_URL}/svg/${icon}.svg`,
webp: `${BASE_URL}/webp/${icon}.webp`,
},
},
}
}
```
**Strengths:**
- Excellent dynamic title generation with proper formatting
- Comprehensive description with icon-specific information
- Proper OpenGraph article configuration with author and timestamp data
- Well-structured alternates configuration for different media types
- Good keyword inclusion in meta tags
**Areas for improvement:**
- Could benefit from structured data for product/image entity
- Could implement dynamic OG images with the ImageResponse API
### Icons Browse Page Metadata Analysis
The icons browse page implements specific metadata optimized for its purpose:
```typescript
// In icons/page.tsx
export async function generateMetadata(): Promise<Metadata> {
const icons = await getIconsArray()
const totalIcons = icons.length
return {
title: "Browse Icons | Free Dashboard Icons",
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools...`,
keywords: [
"browse icons",
"dashboard icons",
"icon search",
// ...
],
openGraph: {
title: "Browse Icons | Free Dashboard Icons",
description: `Search and browse through our collection of ${totalIcons} curated icons...`,
// ...
},
// Additional configurations...
}
}
```
**Strengths:**
- Clear, purpose-driven title
- Dynamic description that includes the collection size
- Relevant keywords for the browse page functionality
**Areas for improvement:**
- Could implement pagination metadata (prev/next) if applicable
- Missing structured data for collection/gallery
## SEO Optimization Checklist
### Metadata and Head Tags
- [x] Page titles are unique, descriptive, and include keywords
- [x] Meta descriptions are compelling and keyword-rich (under 160 characters)
- [x] Open Graph tags are implemented for social sharing
- [x] Twitter Card metadata is implemented
- [x] Canonical URLs are properly set
- [ ] Structured data/JSON-LD for rich snippets
- [x] Properly configured viewport meta tag
- [x] Favicon and apple-touch-icon are set
- [x] Keywords meta tag is implemented (though not as influential for rankings as before)
- [ ] Language and locale information (hreflang) for international SEO
### Indexation and Crawling
- [x] Server-side rendering for improved indexability
- [ ] robots.txt file implementation
- [ ] XML sitemap generation
- [x] Proper HTTP status codes (200, 404, etc.)
- [x] Internal linking structure
- [ ] Pagination handling with proper rel="next" and rel="prev" tags
- [ ] Implementation of dynamic sitemap with Next.js 15.3 file-based API
### Content Structure
- [x] Clean URL structure (`/icons/[icon]`)
- [x] Semantic HTML headings (h1, h2, etc.)
- [x] Content hierarchy matches visual hierarchy
- [ ] Breadcrumb navigation for improved user experience and crawlability
- [ ] Schema.org markup for content types
## Technical SEO
### Server-side Rendering and Static Generation
The project effectively uses Next.js App Router to implement:
- **Static Generation (SSG)** for homepage and catalog pages, providing fast initial load times and improved indexability
- **Server-Side Rendering (SSR)** for dynamic content, ensuring fresh content is always accessible to crawlers
- **Incremental Static Regeneration (ISR)** potential for optimal performance and content freshness
These approaches ensure search engines can properly crawl and index content while providing optimal performance.
### Dynamic Routes Implementation
Dynamic routes like `/icons/[icon]` are properly implemented with `generateStaticParams` to pre-render paths at build time:
```typescript
export async function generateStaticParams() {
const iconsData = await getAllIcons()
return Object.keys(iconsData).map((icon) => ({
icon,
}))
}
```
This approach ensures all icon pages are pre-rendered during build time, optimizing both performance and SEO by making all content immediately available to search engine crawlers without requiring JavaScript execution.
### Missing Critical Components
#### robots.txt Implementation
Currently missing a robots.txt file which is essential for directing search engine crawlers. Next.js 15.3 offers a file-based API that should be implemented:
```typescript
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: 'https://dashboardicons.com/sitemap.xml',
}
}
```
#### sitemap.xml Implementation
No sitemap implementation was found. A sitemap is critical for search engines to discover and index all pages efficiently. Next.js 15.3's file-based API makes this easy to implement:
```typescript
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllIcons } from '@/lib/api'
import { BASE_URL, WEB_URL } from '@/constants'
export default async function sitemap(): MetadataRoute.Sitemap {
const iconsData = await getAllIcons()
const lastModified = new Date()
// Base routes
const routes = [
{
url: WEB_URL,
lastModified,
changeFrequency: 'weekly',
priority: 1.0,
},
{
url: `${WEB_URL}/icons`,
lastModified,
changeFrequency: 'daily',
priority: 0.9,
},
// Other static routes
]
// Icon routes
const iconRoutes = Object.keys(iconsData).map((icon) => ({
url: `${WEB_URL}/icons/${icon}`,
lastModified: new Date(iconsData[icon].update.timestamp),
changeFrequency: 'weekly' as const,
priority: 0.7,
}))
return [...routes, ...iconRoutes]
}
```
For larger icon collections, Next.js 15.3 supports `generateSitemaps` for creating multiple sitemap files:
```typescript
// app/sitemap.ts
export async function generateSitemaps() {
const totalIcons = await getTotalIconCount()
// Google's limit is 50,000 URLs per sitemap
const sitemapsNeeded = Math.ceil(totalIcons / 50000)
return Array.from({ length: sitemapsNeeded }, (_, i) => ({ id: i }))
}
export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
// Fetch icons for this specific sitemap segment
// ...implementation
}
```
#### JSON-LD Structured Data
Missing structured data for improved search results appearance. For icon pages, implement ImageObject schema:
```typescript
// In [icon]/page.tsx component
import { JsonLd } from 'next-seo';
// Within component return statement
return (
<>
<JsonLd
type="ImageObject"
data={{
'@context': 'https://schema.org',
'@type': 'ImageObject',
name: `${formattedIconName} Icon`,
description: `Dashboard icon for ${formattedIconName}`,
contentUrl: `${BASE_URL}/png/${icon}.png`,
license: 'https://creativecommons.org/licenses/by-sa/4.0/',
acquireLicensePage: `${WEB_URL}/icons/${icon}`,
creditText: `Dashboard Icons`,
creator: {
'@type': 'Person',
name: authorData.name || authorData.login
}
}}
/>
<IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
</>
)
```
For the homepage, implement Organization schema:
```typescript
// In layout.tsx or page.tsx
import { JsonLd } from 'next-seo';
// Within component return statement
<JsonLd
type="Organization"
data={{
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Dashboard Icons',
url: WEB_URL,
logo: `${WEB_URL}/logo.png`,
description: 'Collection of free icons for self-hosted dashboards and services',
sameAs: [
REPO_PATH,
// Social media links if available
]
}}
/>
```
## Performance Optimization
### Core Web Vitals
Performance is a crucial SEO factor. Current implementation has:
- [x] Image optimization through next/image (reduces LCP)
- [x] Font optimization with the Inter variable font
- [ ] Proper lazy loading of below-the-fold content
- [ ] Optimized Cumulative Layout Shift (CLS)
- [ ] Interaction to Next Paint (INP) optimization
### Detailed Recommendations
#### 1. Image Optimization
- **Priority attribute**: Add priority attribute to critical above-the-fold images:
```tsx
<Image
src="/hero-image.jpg"
alt="Dashboard Icons"
width={1200}
height={630}
priority
/>
```
- **Size optimization**: Ensure images use appropriate sizes for their display contexts:
```tsx
<Image
src={`${BASE_URL}/png/${icon}.png`}
alt={`${formattedIconName} icon`}
width={64}
height={64}
sizes="(max-width: 640px) 32px, (max-width: 1024px) 48px, 64px"
/>
```
#### 2. JavaScript Optimization
- **Use dynamic imports**: Implement dynamic imports for non-critical components:
```tsx
import dynamic from 'next/dynamic'
const IconGrid = dynamic(() => import('@/components/IconGrid'), {
loading: () => <p>Loading icons...</p>,
})
```
- **Component-level code splitting**: Break large components into smaller, more manageable pieces
#### 3. Core Web Vitals Focus
- **LCP Optimization**:
- Preload critical resources
- Optimize server response time
- Prioritize above-the-fold content rendering
- **CLS Minimization**:
- Reserve space for dynamic content
- Define explicit width/height for images and embeds
- Avoid inserting content above existing content
- **INP Improvement**:
- Optimize event handlers
- Use debouncing for input-related events
- Avoid long-running JavaScript tasks
## Content and User Experience
- [x] Clean, semantic HTML structure
- [x] Clear content hierarchy with proper heading tags
- [ ] Comprehensive alt text for all images
- [x] Mobile-friendly responsive design
- [ ] Breadcrumb navigation for improved user experience and SEO
- [ ] Related icons section for internal linking and improved user engagement
### Recommended Content Improvements
#### Breadcrumb Navigation
Implement structured breadcrumb navigation with Schema.org markup:
```tsx
// components/Breadcrumbs.tsx
import Link from 'next/link'
import { JsonLd } from 'next-seo'
interface BreadcrumbItem {
name: string
url: string
}
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
return (
<>
<JsonLd
type="BreadcrumbList"
data={{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
}}
/>
<nav aria-label="Breadcrumb" className="breadcrumbs">
<ol>
{items.map((item, index) => (
<li key={item.url}>
{index < items.length - 1 ? (
<Link href={item.url}>{item.name}</Link>
) : (
<span aria-current="page">{item.name}</span>
)}
</li>
))}
</ol>
</nav>
</>
)
}
```
#### Related Icons Section
Add a related icons section to improve internal linking and user engagement:
```tsx
// components/RelatedIcons.tsx
import Link from 'next/link'
import Image from 'next/image'
import { BASE_URL } from '@/constants'
export function RelatedIcons({
currentIcon,
similarIcons
}: {
currentIcon: string,
similarIcons: string[]
}) {
return (
<section aria-labelledby="related-icons-heading">
<h2 id="related-icons-heading">Related Icons</h2>
<div className="icon-grid">
{similarIcons.map(icon => (
<Link
key={icon}
href={`/icons/${icon}`}
className="icon-card"
>
<Image
src={`${BASE_URL}/png/${icon}.png`}
alt={`${icon.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} icon`}
width={48}
height={48}
/>
<span>{icon.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}</span>
</Link>
))}
</div>
</section>
)
}
```
## Mobile Optimization
- [x] Responsive design with fluid layouts
- [x] Appropriate viewport configuration:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, minimumScale=1, maximumScale=5, userScalable=true, themeColor=#ffffff, viewportFit=cover" />
```
- [ ] Touch-friendly navigation and interface elements (minimum 44x44px tap targets)
- [ ] Mobile page speed optimization (reduced JavaScript, optimized images)
### Mobile-Specific Recommendations
1. **Implement mobile-specific image handling**:
```tsx
<Image
src={`${BASE_URL}/png/${icon}.png`}
alt={`${formattedIconName} icon`}
width={64}
height={64}
sizes="(max-width: 480px) 32px, 64px"
quality={90}
/>
```
2. **Enhanced touch targets for mobile**:
```css
@media (max-width: 768px) {
.nav-link, .button, .interactive-element {
min-height: 44px;
min-width: 44px;
padding: 12px;
}
}
```
3. **Simplified navigation for mobile**:
Implement a hamburger menu or collapsible navigation for mobile devices
## Advanced Next.js 15.3 SEO Features
Next.js 15.3 offers enhanced SEO features that should be implemented:
### Dynamic OG Images
Implement dynamic Open Graph images using the ImageResponse API:
```typescript
// app/icons/[icon]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getAllIcons } from '@/lib/api'
import { BASE_URL } from '@/constants'
export const runtime = 'edge'
export const alt = 'Dashboard Icon Preview'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function OgImage({ params }: { params: { icon: string } }) {
const { icon } = params
const iconsData = await getAllIcons()
const iconData = iconsData[icon]
if (!iconData) {
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#f8fafc',
color: '#334155',
fontFamily: 'sans-serif',
}}
>
<h1 style={{ fontSize: 64 }}>Icon Not Found</h1>
</div>
)
)
}
const formattedIconName = icon
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#f8fafc',
color: '#334155',
fontFamily: 'sans-serif',
padding: 40,
}}
>
<img
src={`${BASE_URL}/png/${icon}.png`}
width={200}
height={200}
alt={`${formattedIconName} icon`}
style={{ marginBottom: 40 }}
/>
<h1 style={{ fontSize: 64, marginBottom: 20, textAlign: 'center' }}>
{formattedIconName} Icon
</h1>
<p style={{ fontSize: 32, textAlign: 'center' }}>
Free download in SVG, PNG, and WEBP formats
</p>
</div>
)
)
}
```
### Next.js Route Segments for SEO
Utilize route segment config options to optimize SEO aspects:
```typescript
// app/icons/[icon]/page.tsx
export const dynamic = 'force-static' // Ensure static generation even with dynamic data fetching
export const revalidate = 3600 // Revalidate content every hour
export const fetchCache = 'force-cache' // Enforce caching of fetched data
export const generateStaticParams = async () => {
// Generate static paths for all icons
const iconsData = await getAllIcons()
return Object.keys(iconsData).map((icon) => ({ icon }))
}
```
### Advanced Caching Strategies
Implement advanced caching with revalidation tags for dynamic content:
```typescript
// lib/api.ts
import { cache, revalidateTag } from 'next/cache'
// Cache API calls using tags
export const getTotalIcons = cache(
async () => {
const response = await fetch(METADATA_URL, {
next: { tags: ['icons-metadata'] },
})
const data = await response.json()
return { totalIcons: Object.keys(data).length }
}
)
// Function to trigger revalidation when new icons are added
export async function revalidateIconsCache() {
revalidateTag('icons-metadata')
}
```
## Recommendations
### Immediate (High Impact/Low Effort)
1. **Create robots.txt**
- Implement a file-based robots.txt using Next.js 15.3 API
- Include sitemap reference
```typescript
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: 'https://dashboardicons.com/sitemap.xml',
}
}
```
2. **Generate XML Sitemap**
- Create a dynamic sitemap.xml file using Next.js 15.3 file-based API
- Include changefreq and priority attributes
- Implement sitemap index for large icon collections
```typescript
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllIcons } from '@/lib/api'
import { WEB_URL } from '@/constants'
export default async function sitemap(): MetadataRoute.Sitemap {
const iconsData = await getAllIcons()
// Implementation as shown in Technical SEO section
}
```
3. **Add Structured Data**
- Implement JSON-LD for icon pages (ImageObject schema)
- Add WebSite schema to the homepage
- Include BreadcrumbList schema for navigation
```typescript
// app/layout.tsx
import { JsonLd } from 'next-seo'
// In component return
<JsonLd
type="WebSite"
data={{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Dashboard Icons',
url: WEB_URL,
description: getDescription(totalIcons),
potentialAction: {
'@type': 'SearchAction',
target: `${WEB_URL}/icons?q={search_term_string}`,
'query-input': 'required name=search_term_string'
}
}}
/>
```
4. **Enhance Internal Linking**
- Implement breadcrumb navigation
- Add "related icons" or "similar icons" sections
- Create more internal links between icon categories or tags
### Medium-term Improvements
1. **Performance Optimization**
- Implement priority attribute for critical images
- Optimize component-level code splitting
- Refine Largest Contentful Paint (LCP) elements
2. **Enhanced Metadata**
- Implement dynamic OG images with the ImageResponse API
- Add more specific structured data for each icon category
- Implement comprehensive hreflang tags if multilingual support is added
3. **Content Enhancement**
- Add more descriptive text for each icon
- Include usage examples and contexts
- Improve alt text for all images with detailed descriptions
### Long-term Strategy
1. **Advanced Metrics Tracking**
- Implement Real User Monitoring (RUM)
- Set up Core Web Vitals tracking in the field
- Establish regular SEO audit cycles
2. **Enhanced User Experience**
- Implement advanced search functionality with filtering options
- Add user collections/favorites feature
- Develop a comprehensive filtering system by icon type, style, color, etc.
3. **Content Expansion**
- Add tutorials on how to use the icons
- Create themed icon collections
- Implement a blog for icon design tips and updates
## Conclusion
### Overall SEO Health Assessment
The Dashboard Icons website currently implements many SEO best practices through Next.js 15.3's App Router features. The project demonstrates strong implementation of:
- Metadata configuration with the built-in Metadata API
- Dynamic generation of page-specific metadata
- Open Graph and Twitter Card integration
- Server-side rendering and static generation
- Proper canonical URL management
- Clean, semantic HTML structure
- Responsive design for mobile devices
However, several critical components are missing that would significantly improve search engine visibility:
1. **Missing Technical Components**:
- No robots.txt file
- No XML sitemap
- No structured data (JSON-LD)
- Limited use of Next.js 15.3's advanced features
2. **Performance Optimization Gaps**:
- Missing priority attributes on critical images
- Limited implementation of advanced caching strategies
- Potential Core Web Vitals optimizations
3. **Enhanced User Experience Opportunities**:
- No breadcrumb navigation
- Limited internal linking between related icons
- Missing advanced search and filtering capabilities
### SEO Implementation Score
| Category | Score | Notes |
|----------|-------|-------|
| Metadata Implementation | 8/10 | Strong implementation, missing structured data |
| Technical SEO | 6/10 | Missing robots.txt and sitemap |
| Performance | 7/10 | Good image optimization, room for improvement |
| Content Structure | 7/10 | Semantic HTML present, needs better internal linking |
| Mobile Optimization | 8/10 | Responsive design, opportunity for touch optimizations |
| Next.js 15.3 Features | 5/10 | Not utilizing latest features like dynamic OG images |
| Overall | 6.8/10 | Good foundation, specific improvements needed |
### Priority Action Items
1. **Immediate (High Impact/Low Effort)**:
- Create robots.txt file using file-based API
- Generate XML sitemap with Next.js 15.3 API
- Add JSON-LD structured data to all page types
2. **Short-term (Medium Impact)**:
- Optimize Core Web Vitals (LCP, CLS, INP)
- Add priority attribute to above-the-fold images
- Implement breadcrumb navigation with schema
3. **Long-term (Strategic)**:
- Implement dynamic OG images with ImageResponse API
- Add more descriptive content for each icon
- Develop a comprehensive internal linking strategy
- Consider content expansion with tutorials and icon usage guides
By implementing these SEO improvements, Dashboard Icons will significantly enhance its search engine visibility, user experience, and overall organic traffic growth potential. The existing implementation provides a solid foundation, and these targeted enhancements will help maximize the site's search performance in an increasingly competitive landscape.
## References
1. [Next.js 15.3 Metadata API Documentation](https://nextjs.org/docs/app/building-your-application/optimizing/metadata)
2. [Google's SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide)
3. [Next.js File-Based Metadata](https://nextjs.org/docs/app/api-reference/file-conventions/metadata)
4. [Core Web Vitals - Google Web Dev](https://web.dev/articles/vitals)
5. [Schema.org Documentation](https://schema.org/docs/documents.html)
6. [Next.js Image Component Documentation](https://nextjs.org/docs/app/api-reference/components/image)
7. [Next.js ImageResponse API](https://nextjs.org/docs/app/api-reference/functions/image-response)
8. [Google Search Central Documentation](https://developers.google.com/search)
9. [Next.js 15.3 App Router SEO Checklist](https://dev.to/simplr_sh/nextjs-15-app-router-seo-comprehensive-checklist-3d3f)
10. [Mobile Optimization - Google Search Central](https://developers.google.com/search/mobile-sites)
11. [Next.js 15.3 Performance Optimization](https://nextjs.org/docs/app/building-your-application/optimizing)

2
web/netlify.toml Normal file
View File

@@ -0,0 +1,2 @@
[build]
ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF web"

228
web/pnpm-lock.yaml generated
View File

@@ -160,7 +160,7 @@ importers:
version: 3.2.0
tailwindcss-motion:
specifier: ^1.1.0
version: 1.1.0(tailwindcss@4.1.3)
version: 1.1.0(tailwindcss@4.1.4)
tw-animate-css:
specifier: ^1.2.5
version: 1.2.5
@@ -176,13 +176,13 @@ importers:
version: 1.9.4
'@tailwindcss/postcss':
specifier: ^4.1.3
version: 4.1.3
version: 4.1.4
'@types/canvas-confetti':
specifier: ^1.9.0
version: 1.9.0
'@types/node':
specifier: ^22.14.0
version: 22.14.0
version: 22.14.1
'@types/react':
specifier: ^19.1.0
version: 19.1.0
@@ -191,13 +191,13 @@ importers:
version: 19.1.2(@types/react@19.1.0)
tailwindcss:
specifier: ^4.1.3
version: 4.1.3
version: 4.1.4
typescript:
specifier: ^5.8.3
version: 5.8.3
wrangler:
specifier: ^4.12.0
version: 4.12.0
version: 4.12.1
packages:
@@ -275,32 +275,32 @@ packages:
workerd:
optional: true
'@cloudflare/workerd-darwin-64@1.20250416.0':
resolution: {integrity: sha512-aZgF8Swp9eVYxJPWOoZbAgAaYjWuYqGmEA+QJ2ecRGDBqm87rT4GEw7/mmLpxrpllny3VfEEhkk9iYCGv8nlFw==}
'@cloudflare/workerd-darwin-64@1.20250417.0':
resolution: {integrity: sha512-4Adfl92aKepjxb8e6af2d+xpD2sBOADgHqvkyXsFmoLb80weMEDDRGJi1p1m5q1M78/oVnGcpdmuRCAathanRg==}
engines: {node: '>=16'}
cpu: [x64]
os: [darwin]
'@cloudflare/workerd-darwin-arm64@1.20250416.0':
resolution: {integrity: sha512-FhswG1QYRfaTZ4FAlUkfVWaoM2lrlqumiBTrhbo9czMJdGR/oBXS4SGynuI6zyhApHeBf3/fZpA/SBAe4cXdgg==}
'@cloudflare/workerd-darwin-arm64@1.20250417.0':
resolution: {integrity: sha512-dSlk18F4i3T1OTzFBxx3pKpXRMP6w2xZ26+oIV32BFWrCi/HxGzUd6gVA0q37oLGqITRt8xU693J4Gl1CwC/Ag==}
engines: {node: '>=16'}
cpu: [arm64]
os: [darwin]
'@cloudflare/workerd-linux-64@1.20250416.0':
resolution: {integrity: sha512-G+nXEAJ/9y+A857XShwxKeRdfxok6UcjiQe6G+wQeCn/Ofkp/EWydacKdyeVU6QIm1oHS78DwJ7AzbCYywf9aw==}
'@cloudflare/workerd-linux-64@1.20250417.0':
resolution: {integrity: sha512-27MVzOa/lENcqewC2L9EcqstXW843UhjBMcwV1umDfsjwLyZOEv6Gtm/6j5r0L0gASvkRTam3fAmtPk/gt48TA==}
engines: {node: '>=16'}
cpu: [x64]
os: [linux]
'@cloudflare/workerd-linux-arm64@1.20250416.0':
resolution: {integrity: sha512-U6oVW0d9w1fpnDYNrjPJ9SFkDlGJWJWbXHlTBObXl6vccP16WewvuxyHkKqyUhUc8hyBaph7sxeKzKmuCFQ4SA==}
'@cloudflare/workerd-linux-arm64@1.20250417.0':
resolution: {integrity: sha512-34qBk0htAXmUneOTQxW6/g6pjNVR91r0vJzz2FID84cAIOYVl4hZLijkjmVl+MMDU6boXUs+yDwhItdg06YvAg==}
engines: {node: '>=16'}
cpu: [arm64]
os: [linux]
'@cloudflare/workerd-windows-64@1.20250416.0':
resolution: {integrity: sha512-YAjjTzL1z9YYeN4sqYfj1dtQXd2Bblj+B+hl4Rz2aOhblpZEZAdhapZlOCRvLLkOJshKJUnRD3mDlytAdgwybQ==}
'@cloudflare/workerd-windows-64@1.20250417.0':
resolution: {integrity: sha512-PDwATFioff+geVHfgTzSWsxgwjgotrdXStb0EL0lMyMT5zNmHArAnOx83CbDtud63Uv9rVX1BAfPP4tyD1O+5A==}
engines: {node: '>=16'}
cpu: [x64]
os: [win32]
@@ -1365,81 +1365,93 @@ packages:
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tailwindcss/node@4.1.3':
resolution: {integrity: sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==}
'@tailwindcss/node@4.1.4':
resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==}
'@tailwindcss/oxide-android-arm64@4.1.3':
resolution: {integrity: sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==}
'@tailwindcss/oxide-android-arm64@4.1.4':
resolution: {integrity: sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.3':
resolution: {integrity: sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==}
'@tailwindcss/oxide-darwin-arm64@4.1.4':
resolution: {integrity: sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.3':
resolution: {integrity: sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==}
'@tailwindcss/oxide-darwin-x64@4.1.4':
resolution: {integrity: sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.3':
resolution: {integrity: sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==}
'@tailwindcss/oxide-freebsd-x64@4.1.4':
resolution: {integrity: sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3':
resolution: {integrity: sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==}
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4':
resolution: {integrity: sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.3':
resolution: {integrity: sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==}
'@tailwindcss/oxide-linux-arm64-gnu@4.1.4':
resolution: {integrity: sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.3':
resolution: {integrity: sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==}
'@tailwindcss/oxide-linux-arm64-musl@4.1.4':
resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.3':
resolution: {integrity: sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==}
'@tailwindcss/oxide-linux-x64-gnu@4.1.4':
resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.3':
resolution: {integrity: sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==}
'@tailwindcss/oxide-linux-x64-musl@4.1.4':
resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-win32-arm64-msvc@4.1.3':
resolution: {integrity: sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==}
'@tailwindcss/oxide-wasm32-wasi@4.1.4':
resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.4':
resolution: {integrity: sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.3':
resolution: {integrity: sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==}
'@tailwindcss/oxide-win32-x64-msvc@4.1.4':
resolution: {integrity: sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.3':
resolution: {integrity: sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==}
'@tailwindcss/oxide@4.1.4':
resolution: {integrity: sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==}
engines: {node: '>= 10'}
'@tailwindcss/postcss@4.1.3':
resolution: {integrity: sha512-6s5nJODm98F++QT49qn8xJKHQRamhYHfMi3X7/ltxiSQ9dyRsaFSfFkfaMsanWzf+TMYQtbk8mt5f6cCVXJwfg==}
'@tailwindcss/postcss@4.1.4':
resolution: {integrity: sha512-bjV6sqycCEa+AQSt2Kr7wpGF1bOZJ5wsqnLEkqSbM/JEHxx/yhMH8wHmdkPyApF9xhHeMSwnnkDUUMMM/hYnXw==}
'@tanstack/react-virtual@3.13.6':
resolution: {integrity: sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==}
@@ -1480,8 +1492,8 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/node@22.14.0':
resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==}
'@types/node@22.14.1':
resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==}
'@types/react-dom@19.1.2':
resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==}
@@ -1888,8 +1900,8 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
miniflare@4.20250416.0:
resolution: {integrity: sha512-261PhPgD9zs5/BTdbWqwiaXtWxb+Av5zKCwTU+HXrA5E4tf3qnULwh3u6SVUOAEArEroFuKJzawsQ9COtNBurQ==}
miniflare@4.20250417.0:
resolution: {integrity: sha512-bROKLQKr4CoS93tnGuw5e08VaNwM3VowTL3Z2Cps1HzY6a4Bq8uNtggQ7WogriMq77jcHn6kbz64bvWyF//Jkw==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -2153,8 +2165,8 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
tailwindcss@4.1.3:
resolution: {integrity: sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==}
tailwindcss@4.1.4:
resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==}
tapable@2.2.1:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
@@ -2219,17 +2231,17 @@ packages:
web-vitals@4.2.4:
resolution: {integrity: sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==}
workerd@1.20250416.0:
resolution: {integrity: sha512-Yrx/bZAKbmSvomdTAzzIpOHwpYhs0ldr2wqed22UEhQ0mIplAHY4xmY+SjAJhP/TydZrciOVzBxwM1+4T40KNA==}
workerd@1.20250417.0:
resolution: {integrity: sha512-naz6oJiVODd3/Lkp9l3vtc56HKOOvx+AWDvEsTa5eSfi5SI9V0HYpLYSPblAwrfazbQ4ff1Vl3jkTl/5JxqCAA==}
engines: {node: '>=16'}
hasBin: true
wrangler@4.12.0:
resolution: {integrity: sha512-4rfAXOi5KqM3ECvOrZJ97k3zEqxVwtdt4bijd8jcRBZ6iJYvEtjgjVi4TsfkVa/eXGhpfHTUnKu2uk8UHa8M2w==}
wrangler@4.12.1:
resolution: {integrity: sha512-jYrz8y2ffhsRqvQLO2dXFi9HLvPUJk3jn7U71GWfBBCHm0I6r2ik7Vs9ajpRcTGlbNw1RY0uIHVJBVR/7bEN5A==}
engines: {node: '>=18.0.0'}
hasBin: true
peerDependencies:
'@cloudflare/workers-types': ^4.20250415.0
'@cloudflare/workers-types': ^4.20250417.0
peerDependenciesMeta:
'@cloudflare/workers-types':
optional: true
@@ -2302,25 +2314,25 @@ snapshots:
dependencies:
mime: 3.0.0
'@cloudflare/unenv-preset@2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250416.0)':
'@cloudflare/unenv-preset@2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250417.0)':
dependencies:
unenv: 2.0.0-rc.15
optionalDependencies:
workerd: 1.20250416.0
workerd: 1.20250417.0
'@cloudflare/workerd-darwin-64@1.20250416.0':
'@cloudflare/workerd-darwin-64@1.20250417.0':
optional: true
'@cloudflare/workerd-darwin-arm64@1.20250416.0':
'@cloudflare/workerd-darwin-arm64@1.20250417.0':
optional: true
'@cloudflare/workerd-linux-64@1.20250416.0':
'@cloudflare/workerd-linux-64@1.20250417.0':
optional: true
'@cloudflare/workerd-linux-arm64@1.20250416.0':
'@cloudflare/workerd-linux-arm64@1.20250417.0':
optional: true
'@cloudflare/workerd-windows-64@1.20250416.0':
'@cloudflare/workerd-windows-64@1.20250417.0':
optional: true
'@cspotcode/source-map-support@0.8.1':
@@ -3264,67 +3276,71 @@ snapshots:
dependencies:
tslib: 2.8.1
'@tailwindcss/node@4.1.3':
'@tailwindcss/node@4.1.4':
dependencies:
enhanced-resolve: 5.18.1
jiti: 2.4.2
lightningcss: 1.29.2
tailwindcss: 4.1.3
tailwindcss: 4.1.4
'@tailwindcss/oxide-android-arm64@4.1.3':
'@tailwindcss/oxide-android-arm64@4.1.4':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.3':
'@tailwindcss/oxide-darwin-arm64@4.1.4':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.3':
'@tailwindcss/oxide-darwin-x64@4.1.4':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.3':
'@tailwindcss/oxide-freebsd-x64@4.1.4':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.3':
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.3':
'@tailwindcss/oxide-linux-arm64-gnu@4.1.4':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.3':
'@tailwindcss/oxide-linux-arm64-musl@4.1.4':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.3':
'@tailwindcss/oxide-linux-x64-gnu@4.1.4':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.3':
'@tailwindcss/oxide-linux-x64-musl@4.1.4':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.3':
'@tailwindcss/oxide-wasm32-wasi@4.1.4':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.3':
'@tailwindcss/oxide-win32-arm64-msvc@4.1.4':
optional: true
'@tailwindcss/oxide@4.1.3':
'@tailwindcss/oxide-win32-x64-msvc@4.1.4':
optional: true
'@tailwindcss/oxide@4.1.4':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.3
'@tailwindcss/oxide-darwin-arm64': 4.1.3
'@tailwindcss/oxide-darwin-x64': 4.1.3
'@tailwindcss/oxide-freebsd-x64': 4.1.3
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.3
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.3
'@tailwindcss/oxide-linux-arm64-musl': 4.1.3
'@tailwindcss/oxide-linux-x64-gnu': 4.1.3
'@tailwindcss/oxide-linux-x64-musl': 4.1.3
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.3
'@tailwindcss/oxide-win32-x64-msvc': 4.1.3
'@tailwindcss/oxide-android-arm64': 4.1.4
'@tailwindcss/oxide-darwin-arm64': 4.1.4
'@tailwindcss/oxide-darwin-x64': 4.1.4
'@tailwindcss/oxide-freebsd-x64': 4.1.4
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.4
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.4
'@tailwindcss/oxide-linux-arm64-musl': 4.1.4
'@tailwindcss/oxide-linux-x64-gnu': 4.1.4
'@tailwindcss/oxide-linux-x64-musl': 4.1.4
'@tailwindcss/oxide-wasm32-wasi': 4.1.4
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.4
'@tailwindcss/oxide-win32-x64-msvc': 4.1.4
'@tailwindcss/postcss@4.1.3':
'@tailwindcss/postcss@4.1.4':
dependencies:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.1.3
'@tailwindcss/oxide': 4.1.3
'@tailwindcss/node': 4.1.4
'@tailwindcss/oxide': 4.1.4
postcss: 8.5.3
tailwindcss: 4.1.3
tailwindcss: 4.1.4
'@tanstack/react-virtual@3.13.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
@@ -3360,7 +3376,7 @@ snapshots:
'@types/d3-timer@3.0.2': {}
'@types/node@22.14.0':
'@types/node@22.14.1':
dependencies:
undici-types: 6.21.0
@@ -3735,7 +3751,7 @@ snapshots:
mime@3.0.0: {}
miniflare@4.20250416.0:
miniflare@4.20250417.0:
dependencies:
'@cspotcode/source-map-support': 0.8.1
acorn: 8.14.0
@@ -3744,7 +3760,7 @@ snapshots:
glob-to-regexp: 0.4.1
stoppable: 1.1.0
undici: 5.29.0
workerd: 1.20250416.0
workerd: 1.20250417.0
ws: 8.18.0
youch: 3.3.4
zod: 3.22.3
@@ -4025,11 +4041,11 @@ snapshots:
tailwind-merge@3.2.0: {}
tailwindcss-motion@1.1.0(tailwindcss@4.1.3):
tailwindcss-motion@1.1.0(tailwindcss@4.1.4):
dependencies:
tailwindcss: 4.1.3
tailwindcss: 4.1.4
tailwindcss@4.1.3: {}
tailwindcss@4.1.4: {}
tapable@2.2.1: {}
@@ -4100,24 +4116,24 @@ snapshots:
web-vitals@4.2.4: {}
workerd@1.20250416.0:
workerd@1.20250417.0:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20250416.0
'@cloudflare/workerd-darwin-arm64': 1.20250416.0
'@cloudflare/workerd-linux-64': 1.20250416.0
'@cloudflare/workerd-linux-arm64': 1.20250416.0
'@cloudflare/workerd-windows-64': 1.20250416.0
'@cloudflare/workerd-darwin-64': 1.20250417.0
'@cloudflare/workerd-darwin-arm64': 1.20250417.0
'@cloudflare/workerd-linux-64': 1.20250417.0
'@cloudflare/workerd-linux-arm64': 1.20250417.0
'@cloudflare/workerd-windows-64': 1.20250417.0
wrangler@4.12.0:
wrangler@4.12.1:
dependencies:
'@cloudflare/kv-asset-handler': 0.4.0
'@cloudflare/unenv-preset': 2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250416.0)
'@cloudflare/unenv-preset': 2.3.1(unenv@2.0.0-rc.15)(workerd@1.20250417.0)
blake3-wasm: 2.1.5
esbuild: 0.25.2
miniflare: 4.20250416.0
miniflare: 4.20250417.0
path-to-regexp: 6.3.0
unenv: 2.0.0-rc.15
workerd: 1.20250416.0
workerd: 1.20250417.0
optionalDependencies:
fsevents: 2.3.3
sharp: 0.33.5

View File

@@ -31,17 +31,15 @@ export default function ErrorPage({
<AlertTriangle className="w-8 h-8" />
</div>
<h1 className="text-2xl font-bold">Something went wrong</h1>
<p className="text-muted-foreground">
An unexpected error occurred while loading this page. We've been notified and are looking into it.
</p>
<p className="text-muted-foreground">Unable to load this page. We're looking into the issue.</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
<Button variant="outline" onClick={() => reset()} className="cursor-pointer">
<RefreshCcw className="mr-2 h-4 w-4" />
Try again
Retry
</Button>
<Button onClick={handleGoBack} className="cursor-pointer">
<ArrowLeft className="mr-2 h-4 w-4" />
Go back
Back
</Button>
</div>
{error.digest && <p className="text-xs text-muted-foreground mt-6">Error ID: {error.digest}</p>}

View File

@@ -118,19 +118,6 @@
transform: rotate(-5deg) scale(0.9);
}
}
--animate-shiny-text: shiny-text 8s infinite;
@keyframes shiny-text {
0%,
90%,
100% {
background-position: calc(-100% - var(--shiny-width)) 0;
}
30%,
60% {
background-position: calc(100% + var(--shiny-width)) 0;
}
}
}
:root {
@@ -199,7 +186,7 @@
--secondary: oklch(0.31 0.03 266.71);
--secondary-foreground: oklch(0.92 0 0);
--muted: oklch(0.31 0.03 266.71);
--muted-foreground: oklch(0.72 0 0);
--muted-foreground: oklch(0.78 0 0);
--accent: oklch(0.34 0.06 267.59);
--accent-foreground: oklch(0.88 0.06 254.13);
--destructive: oklch(0.64 0.21 25.33);

View File

@@ -7,6 +7,14 @@ export const dynamic = "force-static"
export async function generateStaticParams() {
const iconsData = await getAllIcons()
if (process.env.CI_MODE === "false") {
// This is meant to speed up the build process in local development
return Object.keys(iconsData)
.slice(0, 5)
.map((icon) => ({
icon,
}))
}
return Object.keys(iconsData).map((icon) => ({
icon,
}))

View File

@@ -1,13 +1,22 @@
import { IconDetails } from "@/components/icon-details"
import { BASE_URL, WEB_URL } from "@/constants"
import { getAllIcons, getAuthorData } from "@/lib/api"
import { formatIconName } from "@/lib/utils"
import type { Metadata, ResolvingMetadata } from "next"
import { default as dynamicImport } from "next/dynamic"
import { notFound } from "next/navigation"
export const dynamicParams = false
export async function generateStaticParams() {
const iconsData = await getAllIcons()
if (process.env.CI_MODE === "false") {
// This is meant to speed up the build process in local development
return Object.keys(iconsData)
.slice(0, 5)
.map((icon) => ({
icon,
}))
}
return Object.keys(iconsData).map((icon) => ({
icon,
}))
@@ -33,7 +42,6 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
console.debug(`Generated metadata for ${icon} by ${authorName} (${authorData.html_url}) updated at ${updateDate.toLocaleString()}`)
const iconImageUrl = `${BASE_URL}/png/${icon}.png`
const pageUrl = `${WEB_URL}/icons/${icon}`
const formattedIconName = icon
.split("-")
@@ -43,7 +51,7 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
return {
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.`,
assets: [iconImageUrl],
assets: [`${BASE_URL}/svg/${icon}.svg`, `${BASE_URL}/png/${icon}.png`, `${BASE_URL}/webp/${icon}.webp`],
keywords: [
`${formattedIconName} icon`,
`${formattedIconName} icon download`,
@@ -57,7 +65,7 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
"app directory",
],
icons: {
icon: iconImageUrl,
icon: `${BASE_URL}/webp/${icon}.webp`,
},
abstract: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
openGraph: {
@@ -75,12 +83,10 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
card: "summary_large_image",
title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
images: [iconImageUrl],
},
alternates: {
canonical: pageUrl,
media: {
png: iconImageUrl,
png: `${BASE_URL}/png/${icon}.png`,
svg: `${BASE_URL}/svg/${icon}.svg`,
webp: `${BASE_URL}/webp/${icon}.webp`,
},
@@ -99,5 +105,5 @@ export default async function IconPage({ params }: { params: Promise<{ icon: str
const authorData = await getAuthorData(originalIconData.update.author.id)
return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} allIcons={iconsData} />
}

View File

@@ -1,87 +0,0 @@
"use client"
import { Input } from "@/components/ui/input"
import { BASE_URL } from "@/constants"
import type { IconSearchProps, IconWithName } from "@/types/icons"
import { Search } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { useState } from "react"
export function IconSearch({ icons, initialQuery = "" }: IconSearchProps) {
const [searchQuery, setSearchQuery] = useState(initialQuery)
const [filteredIcons, setFilteredIcons] = useState<IconWithName[]>(() => {
if (!initialQuery.trim()) return icons
const q = initialQuery.toLowerCase()
return icons.filter(({ name, data }) => {
if (name.toLowerCase().includes(q)) return true
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
return false
})
})
const handleSearch = (query: string) => {
setSearchQuery(query)
if (!query.trim()) {
setFilteredIcons(icons)
return
}
const q = query.toLowerCase()
const filtered = icons.filter(({ name, data }) => {
if (name.toLowerCase().includes(q)) return true
if (data.aliases.some((alias) => alias.toLowerCase().includes(q))) return true
if (data.categories.some((category) => category.toLowerCase().includes(q))) return true
return false
})
setFilteredIcons(filtered)
}
return (
<>
<div className="relative w-full max-w-md">
<Search className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground transition-all duration-300" />
<Input
type="search"
placeholder="Search icons by name, aliases, or categories..."
className="w-full pl-8 transition-all duration-300 text-sm md:text-base"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
{filteredIcons.length === 0 ? (
<div className="text-center py-12">
<h2 className="text-xl font-semibold">No icons found</h2>
<p className="text-muted-foreground mt-2">Try a different search term.</p>
</div>
) : (
<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-8">
{filteredIcons.map(({ name, data }) => (
<Link
key={name}
href={`/icons/${name}`}
className="group flex flex-col items-center p-4 rounded-lg border border-border hover:border-primary hover:bg-accent transition-colors"
>
<div className="relative h-16 w-16 mb-2">
<Image
src={`${BASE_URL}/${data.base}/${name}.${data.base}`}
alt={`${name} icon`}
fill
className="object-contain p-1 group-hover:scale-110 transition-transform"
/>
</div>
<span className="text-sm text-center truncate w-full">{name.replace(/-/g, " ")}</span>
</Link>
))}
</div>
)}
</>
)
}

View File

@@ -0,0 +1,283 @@
import { getAllIcons } from "@/lib/api"
import { ImageResponse } from "next/og"
export const dynamic = "force-static"
export const size = {
width: 1200,
height: 630,
}
// Define a fixed list of representative icons
const representativeIcons = [
"homarr",
"sonarr",
"radarr",
"lidarr",
"readarr",
"prowlarr",
"qbittorrent",
"home-assistant",
"cloudflare",
"github",
"traefik",
"portainer",
"plex",
"jellyfin",
"overseerr",
]
export default async function Image() {
const iconsData = await getAllIcons()
const totalIcons = Object.keys(iconsData).length
// Round down to the nearest 100
const roundedTotalIcons = Math.round(totalIcons / 100) * 100
const iconImages = representativeIcons.map((icon) => ({
name: icon
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" "),
url: `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/${icon}.png`,
}))
return new ImageResponse(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
position: "relative",
fontFamily: "Inter, system-ui, sans-serif",
overflow: "hidden",
backgroundColor: "white",
backgroundImage:
"radial-gradient(circle at 25px 25px, lightgray 2%, transparent 0%), radial-gradient(circle at 75px 75px, lightgray 2%, transparent 0%)",
backgroundSize: "100px 100px",
}}
>
<div
style={{
position: "absolute",
display: "flex",
top: -100,
left: -100,
width: 400,
height: 400,
borderRadius: "50%",
background: "linear-gradient(135deg, rgba(56, 189, 248, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)",
filter: "blur(80px)",
zIndex: 2,
}}
/>
<div
style={{
position: "absolute",
display: "flex",
bottom: -150,
right: -150,
width: 500,
height: 500,
borderRadius: "50%",
background: "linear-gradient(135deg, rgba(249, 115, 22, 0.1) 0%, rgba(234, 88, 12, 0.1) 100%)",
filter: "blur(100px)",
zIndex: 2,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
padding: "50px",
zIndex: 10,
gap: "30px",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
marginBottom: "10px",
}}
>
<div
style={{
fontSize: 64,
display: "flex",
fontWeight: 800,
fontFamily: "monospace",
color: "#0f172a",
lineHeight: 1.1,
textAlign: "center",
}}
>
Dashboard Icons
</div>
<div
style={{
fontSize: 28,
display: "flex",
fontWeight: 500,
color: "#64748b",
lineHeight: 1.4,
textAlign: "center",
maxWidth: 1100,
}}
>
A curated collection of {roundedTotalIcons}+ free icons for dashboards and app directories
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: "20px",
width: "1100px",
margin: "0 auto",
}}
>
{iconImages.map((icon, index) => (
<div
key={index}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "white",
borderRadius: 16,
boxShadow: "0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05)",
padding: "20px",
position: "relative",
overflow: "hidden",
width: "120px",
height: "75px",
margin: "0",
}}
>
<div
style={{
display: "flex",
position: "absolute",
inset: 0,
background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)",
zIndex: 0,
}}
/>
<img
src={icon.url}
alt={icon.name}
width={50}
height={50}
style={{
objectFit: "contain",
position: "relative",
zIndex: 1,
filter: "drop-shadow(0 5px 10px rgba(0, 0, 0, 0.1))",
}}
/>
</div>
))}
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "white",
borderRadius: 16,
boxShadow: "0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05)",
padding: "20px",
position: "relative",
overflow: "hidden",
width: "120px",
height: "75px",
margin: "0",
}}
>
<div
style={{
display: "flex",
position: "absolute",
inset: 0,
background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)",
zIndex: 0,
}}
/>
<div
style={{
display: "flex",
fontSize: 20,
fontWeight: 600,
color: "#64748b",
zIndex: 1,
}}
>
+{totalIcons - representativeIcons.length}
</div>
</div>
</div>
<div
style={{
display: "flex",
gap: 16,
marginTop: 10,
}}
/>
</div>
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 80,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#ffffff",
borderTop: "2px solid rgba(0, 0, 0, 0.05)",
zIndex: 20,
}}
>
<div
style={{
display: "flex",
fontSize: 24,
fontWeight: 600,
color: "#334155",
alignItems: "center",
gap: 10,
}}
>
<div
style={{
display: "flex",
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: "#3b82f6",
marginRight: 4,
}}
/>
dashboardicons.com
</div>
</div>
</div>,
{
...size,
},
)
}

View File

@@ -1,7 +1,7 @@
import { IconSearch } from "@/components/icon-search"
import { BASE_URL } from "@/constants"
import { getIconsArray } from "@/lib/api"
import type { Metadata } from "next"
import { IconSearch } from "./components/icon-search"
export async function generateMetadata(): Promise<Metadata> {
const icons = await getIconsArray()
@@ -25,21 +25,11 @@ export async function generateMetadata(): Promise<Metadata> {
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
type: "website",
url: `${BASE_URL}/icons`,
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Browse Dashboard Icons Collection",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
title: "Browse Icons | Free Dashboard Icons",
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
images: ["/og-image-browse.png"],
},
alternates: {
canonical: `${BASE_URL}/icons`,
@@ -52,19 +42,14 @@ export const dynamic = "force-static"
export default async function IconsPage() {
const icons = await getIconsArray()
return (
<div className="isolate overflow-hidden">
<div className="py-8">
<div className="space-y-4 mb-8 mx-auto max-w-7xl">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Browse icons</h1>
<p className="text-muted-foreground">Search through our collection of {icons.length} beautiful icons.</p>
</div>
</div>
<IconSearch icons={icons} />
<div className="isolate overflow-hidden p-2 mx-auto max-w-7xl">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Browse icons</h1>
<p className="text-muted-foreground mb-1">Search through our collection of {icons.length} beautiful icons.</p>
</div>
</div>
<IconSearch icons={icons} />
</div>
)
}

View File

@@ -2,12 +2,12 @@ import { PostHogProvider } from "@/components/PostHogProvider"
import { Footer } from "@/components/footer"
import { HeaderWrapper } from "@/components/header-wrapper"
import { LicenseNotice } from "@/components/license-notice"
import { BASE_URL, WEB_URL, getDescription, websiteTitle } from "@/constants"
import { getTotalIcons } from "@/lib/api"
import type { Metadata, Viewport } from "next"
import { Inter } from "next/font/google"
import { Toaster } from "sonner"
import "./globals.css"
import { BASE_URL, getDescription, WEB_URL, websiteTitle } from "@/constants"
import { ThemeProvider } from "./theme-provider"
const inter = Inter({

View File

@@ -15,27 +15,23 @@ export default function NotFound({
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center text-red-600 dark:text-red-400">
<AlertTriangle className="w-8 h-8" />
</div>
<h1 className="text-2xl sm:text-3xl font-bold mt-6">Icon not found</h1>
<p className="text-muted-foreground mt-3 max-w-md">
The icon you are looking for could not be found or there was an error loading it.
</p>
<h1 className="text-2xl sm:text-3xl font-bold mt-6">Not found</h1>
<p className="text-muted-foreground mt-3 max-w-md">This icon does not exist or could not be loaded.</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button asChild variant="outline">
<Link href="/icons">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to all icons
Back to icons
</Link>
</Button>
</div>
<div className="border-t border-border pt-8 mt-8">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold">Can't find what you're looking for?</h2>
<p className="text-muted-foreground mt-2">
Contribute to our icon collection by suggesting a new icon or improving an existing one.
</p>
<h2 className="text-xl font-semibold">Missing an icon?</h2>
<p className="text-muted-foreground mt-2">Submit a new icon or suggest improvements to our collection.</p>
</div>
<div className="mt-6">

View File

@@ -7,7 +7,7 @@ import { Suspense, useEffect } from "react"
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (process.env.DISABLE_POSTHOG === "true") return
if (process.env.NEXT_PUBLIC_DISABLE_POSTHOG === "true") return
// biome-ignore lint/style/noNonNullAssertion: The NEXT_PUBLIC_POSTHOG_KEY environment variable is guaranteed to be set in production.
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
ui_host: "https://eu.posthog.com",

View File

@@ -1,21 +1,16 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { useMediaQuery } from "@/hooks/use-media-query"
import { fuzzySearch } from "@/lib/utils"
import { Icon } from "@/types/icons"
import { filterAndSortIcons, formatIconName, fuzzySearch } from "@/lib/utils"
import type { IconWithName } from "@/types/icons"
import { Info, Search as SearchIcon, Tag } from "lucide-react"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
interface CommandMenuProps {
icons: {
name: string
data: {
categories: string[]
aliases: string[]
[key: string]: unknown
}
}[]
icons: IconWithName[]
triggerButtonId?: string
open?: boolean
onOpenChange?: (open: boolean) => void
@@ -42,7 +37,9 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
[externalOnOpenChange],
)
const filteredIcons = getFilteredIcons(icons, query)
const filteredIcons = useMemo(() => filterAndSortIcons({ icons, query, limit: 20 }), [icons, query])
const totalIcons = icons.length
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -59,80 +56,93 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
return () => document.removeEventListener("keydown", handleKeyDown)
}, [isOpen, setIsOpen])
function getFilteredIcons(iconList: CommandMenuProps["icons"], query: string) {
if (!query) {
// Return a limited number of icons when no query is provided
return iconList.slice(0, 8)
}
// Calculate scores for each icon
const scoredIcons = iconList.map((icon) => {
// Calculate scores for different fields
const nameScore = fuzzySearch(icon.name, query) * 2.0 // Give more weight to name matches
// Get max score from aliases
const aliasScore =
icon.data.aliases && icon.data.aliases.length > 0
? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * 1.8 // Increased weight for aliases
: 0
// Get max score from categories
const categoryScore =
icon.data.categories && icon.data.categories.length > 0
? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query)))
: 0
// Use the highest score
const score = Math.max(nameScore, aliasScore, categoryScore)
return { icon, score, matchedField: score === nameScore ? "name" : score === aliasScore ? "alias" : "category" }
})
// Filter icons with a minimum score and sort by highest score
return scoredIcons
.filter((item) => item.score > 0.3) // Higher threshold for more accurate results
.sort((a, b) => b.score - a.score)
.slice(0, 20) // Limit the number of results
.map((item) => item.icon)
}
const handleSelect = (name: string) => {
setIsOpen(false)
router.push(`/icons/${name}`)
}
return (
<CommandDialog open={isOpen} onOpenChange={setIsOpen}>
<CommandInput placeholder="Search for icons by name, category, or purpose..." value={query} onValueChange={setQuery} />
<CommandList>
<CommandEmpty>No matching icons found. Try a different search term or browse all icons.</CommandEmpty>
<CommandGroup heading="Icons">
{filteredIcons.map(({ name, data }) => {
// Find matched alias for display if available
const matchedAlias =
query && data.aliases && data.aliases.length > 0
? data.aliases.find((alias) => alias.toLowerCase().includes(query.toLowerCase()))
: null
const handleBrowseAll = () => {
setIsOpen(false)
router.push("/icons")
}
return (
<CommandItem key={name} value={name} onSelect={() => handleSelect(name)} className="flex items-center gap-2 cursor-pointer">
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-5 w-5 bg-rose-100 dark:bg-rose-900/30 rounded-md flex items-center justify-center">
<span className="text-[10px] font-medium text-rose-800 dark:text-rose-300">{name.substring(0, 2).toUpperCase()}</span>
return (
<CommandDialog open={isOpen} onOpenChange={setIsOpen} contentClassName="bg-background/90 backdrop-blur-sm border border-border/60">
<CommandInput
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
value={query}
onValueChange={setQuery}
/>
<CommandList className="max-h-[300px]">
{/* Icon Results */}
<CommandGroup heading="Icons">
{filteredIcons.length > 0 &&
filteredIcons.map(({ name, data }) => {
const formatedIconName = formatIconName(name)
const hasCategories = data.categories && data.categories.length > 0
return (
<CommandItem
key={name}
value={name}
onSelect={() => handleSelect(name)}
className="flex items-center gap-2 cursor-pointer py-1.5"
>
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-full w-full bg-primary/10 dark:bg-primary/20 rounded-md flex items-center justify-center">
<span className="text-[9px] font-medium text-primary dark:text-primary-foreground">
{name.substring(0, 2).toUpperCase()}
</span>
</div>
</div>
</div>
<span className="flex-grow capitalize">{name.replace(/-/g, " ")}</span>
{matchedAlias && <span className="text-xs text-primary-500 truncate max-w-[100px]">alias: {matchedAlias}</span>}
{!matchedAlias && data.categories && data.categories.length > 0 && (
<span className="text-xs text-muted-foreground truncate max-w-[100px]">
{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
)}
</CommandItem>
)
})}
<span className="flex-grow capitalize font-medium text-sm">{formatedIconName}</span>
{hasCategories && (
<div className="flex gap-1 items-center flex-shrink-0 overflow-hidden max-w-[40%]">
{/* First category */}
<Badge
key={data.categories[0]}
variant="secondary"
className="text-xs font-normal inline-flex items-center gap-1 whitespace-nowrap max-w-[120px] overflow-hidden"
>
<Tag size={8} className="mr-1 flex-shrink-0" />
<span className="truncate">{data.categories[0].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}</span>
</Badge>
{/* "+N" badge if more than one category */}
{data.categories.length > 1 && (
<Badge variant="outline" className="text-xs flex-shrink-0">
+{data.categories.length - 1}
</Badge>
)}
</div>
)}
</CommandItem>
)
})}
</CommandGroup>
<CommandEmpty>
{/* Minimal empty state */}
<div className="py-2 px-2 text-center text-xs text-muted-foreground flex items-center justify-center gap-1.5">
<Info className="h-3.5 w-3.5 text-destructive" /> {/* Smaller red icon */}
<span>No matching icons found.</span>
</div>
</CommandEmpty>
</CommandList>
{/* Separator and Browse section - Styled div outside CommandList */}
<div className="border-t border-border/40 pt-1 mt-1 px-1 pb-1">
<button
type="button"
className="flex items-center gap-2 cursor-pointer rounded-sm px-2 py-1 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground w-full"
onClick={handleBrowseAll}
>
<div className="flex-shrink-0 h-5 w-5 relative">
<div className="h-full w-full bg-primary/80 dark:bg-primary/40 rounded-md flex items-center justify-center">
<SearchIcon className="text-primary-foreground dark:text-primary-200 w-3.5 h-3.5" />
</div>
</div>
<span className="flex-grow text-sm">Browse all icons {totalIcons} available</span>
</button>
</div>
</CommandDialog>
)
}

View File

@@ -1,43 +1,19 @@
"use client"
import { REPO_PATH } from "@/constants"
import { motion } from "framer-motion"
import { ExternalLink, Heart } from "lucide-react"
import { ExternalLink } from "lucide-react"
import Link from "next/link"
import { useState } from "react"
// Pre-define unique IDs for animations to avoid using array indices as keys
const HOVER_HEART_IDS = [
"hover-heart-1",
"hover-heart-2",
"hover-heart-3",
"hover-heart-4",
"hover-heart-5",
"hover-heart-6",
"hover-heart-7",
"hover-heart-8",
]
const BURST_HEART_IDS = ["burst-heart-1", "burst-heart-2", "burst-heart-3", "burst-heart-4", "burst-heart-5"]
import { HeartEasterEgg } from "./heart"
export function Footer() {
const [isHeartHovered, setIsHeartHovered] = useState(false)
const [isHeartFilled, setIsHeartFilled] = useState(false)
// Toggle heart fill state and add extra mini hearts on click
const handleHeartClick = () => {
setIsHeartFilled(!isHeartFilled)
}
return (
<footer className="border-t py-4 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-rose-500/[0.03] via-transparent to-rose-500/[0.03]" />
<div className="absolute inset-0 bg-background bg-gradient-to-r from-primary/[0.03] via-transparent to-primary/[0.03]" />
<div className="container mx-auto mb-2 px-4 md:px-6 relative z-10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
<div className="flex flex-col gap-3">
<h3 className="font-bold text-lg text-foreground/90">Dashboard Icons</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
A collection of curated icons for services, applications and tools, designed specifically for dashboards and app directories.
Collection of icons for applications, services, and tools - designed for dashboards and app directories.
</p>
</div>
@@ -53,117 +29,9 @@ export function Footer() {
</div>
</div>
<motion.div
className="flex flex-col gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="flex flex-col gap-3">
<h3 className="font-bold text-lg text-foreground/90">Community</h3>
<div className="text-sm flex flex-wrap items-center gap-1.5 leading-relaxed">
Made with{" "}
<div className="relative inline-block">
<motion.div
className="cursor-pointer"
onMouseEnter={() => setIsHeartHovered(true)}
onMouseLeave={() => setIsHeartHovered(false)}
onClick={handleHeartClick}
whileTap={{ scale: 0.85 }}
>
<motion.div
animate={{
scale: isHeartFilled ? [1, 1.3, 1] : 1,
}}
transition={{
duration: isHeartFilled ? 0.4 : 0,
ease: "easeInOut",
}}
>
<Heart
className="h-3.5 w-3.5 flex-shrink-0 hover:scale-125 transition-all duration-200"
fill={isHeartFilled ? "#f43f5e" : "none"}
strokeWidth={isHeartFilled ? 1.5 : 2}
/>
</motion.div>
</motion.div>
{/* Easter egg mini hearts */}
{isHeartHovered && (
<>
{HOVER_HEART_IDS.map((id, i) => (
<motion.div
key={id}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, (i % 2 === 0 ? 1 : -1) * Math.random() * 20],
y: [0, -Math.random() * 30],
}}
transition={{
duration: 0.8 + Math.random() * 0.5,
ease: "easeOut",
delay: Math.random() * 0.2,
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className={`h-2 w-2 ${i < 3 ? "text-rose-300" : i < 6 ? "text-rose-400" : ""}`} />
</motion.div>
))}
{/* Subtle particle glow */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 3],
opacity: [0, 0.3, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-rose-500/20 pointer-events-none"
/>
</>
)}
{/* Heart fill animation extras */}
{isHeartFilled && (
<>
{/* Radiating circles on heart fill */}
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: [0.5, 2.5],
opacity: [0.5, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 w-3 h-3 rounded-full bg-rose-500/30 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
/>
{/* Extra burst of mini hearts when filled */}
{BURST_HEART_IDS.map((id, i) => (
<motion.div
key={id}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, Math.cos((i * Math.PI) / 2.5) * 25],
y: [0, Math.sin((i * Math.PI) / 2.5) * 25],
}}
transition={{
duration: 0.6,
ease: "easeOut",
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className="h-2 w-2 " fill="#f43f5e" />
</motion.div>
))}
</>
)}
</div>{" "}
by Homarr Labs and the open source community.
</div>
<HeartEasterEgg />
<Link
href={REPO_PATH}
target="_blank"
@@ -173,7 +41,7 @@ export function Footer() {
Contribute to this project
<ExternalLink className="h-3.5 w-3.5 flex-shrink-0" />
</Link>
</motion.div>
</div>
</div>
</div>
</footer>

View File

@@ -5,8 +5,7 @@ import { ThemeSwitcher } from "@/components/theme-switcher"
import { REPO_PATH } from "@/constants"
import { getIconsArray } from "@/lib/api"
import type { IconWithName } from "@/types/icons"
import { motion } from "framer-motion"
import { Github, Search } from "lucide-react"
import { Github, PlusCircle, Search } from "lucide-react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { CommandMenu } from "./command-menu"
@@ -40,12 +39,7 @@ export function Header() {
}
return (
<motion.header
className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
<header className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50">
<div className="px-4 md:px-12 flex items-center justify-between h-16 md:h-18">
<div className="flex items-center gap-2 md:gap-6">
<Link href="/" className="text-lg md:text-xl font-bold group hidden md:block">
@@ -80,7 +74,20 @@ export function Header() {
</Button>
</div>
{/* Mobile Submit Button -> triggers IconSubmissionForm dialog */}
<div className="md:hidden">
<IconSubmissionForm
trigger={
<Button variant="ghost" size="icon" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 ">
<PlusCircle className="h-5 w-5 transition-all duration-300" />
<span className="sr-only">Submit icon(s)</span>
</Button>
}
/>
</div>
<div className="hidden md:flex items-center gap-2 md:gap-4">
{/* Desktop Submit Button */}
<IconSubmissionForm />
<TooltipProvider>
<Tooltip>
@@ -109,6 +116,6 @@ export function Header() {
{/* Single instance of CommandMenu */}
{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />}
</motion.header>
</header>
)
}

View File

@@ -0,0 +1,121 @@
"use client"
import { Heart } from "lucide-react"
import { motion } from "framer-motion"
import { useState } from "react"
export function HeartEasterEgg() {
const [isHeartHovered, setIsHeartHovered] = useState(false)
const [isHeartFilled, setIsHeartFilled] = useState(false)
const handleHeartClick = () => {
setIsHeartFilled(!isHeartFilled)
}
return (
<div className="text-sm flex flex-wrap items-center gap-1.5 leading-relaxed">
Made with{" "}
<div className="relative inline-block">
<motion.div
className="cursor-pointer"
onMouseEnter={() => setIsHeartHovered(true)}
onMouseLeave={() => setIsHeartHovered(false)}
onClick={handleHeartClick}
whileTap={{ scale: 0.85 }}
>
<motion.div
animate={{
scale: isHeartFilled ? [1, 1.3, 1] : 1,
}}
transition={{
duration: isHeartFilled ? 0.4 : 0,
ease: "easeInOut",
}}
>
<Heart
className="h-3.5 w-3.5 flex-shrink-0 hover:scale-125 transition-all duration-200"
fill={isHeartFilled ? "#f43f5e" : "none"}
strokeWidth={isHeartFilled ? 1.5 : 2}
/>
</motion.div>
</motion.div>
{/* Easter egg mini hearts */}
{isHeartHovered && (
<>
{[...Array(8)].map((_, i) => (
<motion.div
key={i}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, (i % 2 === 0 ? 1 : -1) * Math.random() * 20],
y: [0, -Math.random() * 30],
}}
transition={{
duration: 0.8 + Math.random() * 0.5,
ease: "easeOut",
delay: Math.random() * 0.2,
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className={`h-2 w-2 ${i < 3 ? "text-rose-300" : i < 6 ? "text-rose-400" : ""}`} />
</motion.div>
))}
{/* Subtle particle glow */}
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 3],
opacity: [0, 0.3, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-rose-500/20 pointer-events-none"
/>
</>
)}
{/* Heart fill animation extras */}
{isHeartFilled && (
<>
{/* Radiating circles on heart fill */}
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: [0.5, 2.5],
opacity: [0.5, 0],
}}
transition={{ duration: 0.6, ease: "easeOut" }}
className="absolute left-1/2 top-1/2 w-3 h-3 rounded-full bg-rose-500/30 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
/>
{/* Extra burst of mini hearts when filled */}
{[...Array(8)].map((_, i) => (
<motion.div
key={i}
initial={{ scale: 0, opacity: 0 }}
animate={{
scale: [0, 1, 0.8],
opacity: [0, 1, 0],
x: [0, Math.cos((i * Math.PI) / 2.5) * 25],
y: [0, Math.sin((i * Math.PI) / 2.5) * 25],
}}
transition={{
duration: 0.6,
ease: "easeOut",
}}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
>
<Heart className="h-2 w-2 " fill="#f43f5e" />
</motion.div>
))}
</>
)}
</div>{" "}
by Homarr Labs and the open source community.
</div>
)
}

View File

@@ -224,7 +224,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
<SearchInput searchQuery={searchQuery} setSearchQuery={setSearchQuery} totalIcons={totalIcons} />
<div className="w-full flex gap-3 md:gap-4 flex-wrap justify-center motion-preset-slide-down motion-duration-500">
<Link href="/icons">
<InteractiveHoverButton className="rounded-md bg-input/30">Explore icons</InteractiveHoverButton>
<InteractiveHoverButton className="rounded-md bg-input/30">Browse icons</InteractiveHoverButton>
</Link>
<GiveUsAStarButton stars={stars} />
<GiveUsMoneyButton />
@@ -449,12 +449,12 @@ export function GiveUsMoneyButton() {
<div className="flex justify-between items-center pt-2">
<Link href={openCollectiveUrl} target="_blank" rel="noopener noreferrer">
<Button variant="default" size="sm" className="bg-primary hover:bg-primary/90">
Donate
Support
</Button>
</Link>
<Link href={`${openCollectiveUrl}/transactions`} target="_blank" rel="noopener noreferrer">
<Button variant="link" size="sm" className="flex items-center gap-1 text-xs text-secondary-foreground">
View expenses
View transactions
<ExternalLink className="h-3 w-3" />
</Button>
</Link>
@@ -478,7 +478,7 @@ function SearchInput({ searchQuery, setSearchQuery, totalIcons }: SearchInputPro
name="q"
autoFocus
type="search"
placeholder={`Find any of ${totalIcons} icons by name or category...`}
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
className="pl-10 h-10 md:h-12 rounded-lg w-full border-border focus:border-primary/20 text-sm md:text-base"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}

View File

@@ -0,0 +1,36 @@
import { MagicCard } from "@/components/magicui/magic-card"
import { BASE_URL } from "@/constants"
import { formatIconName } from "@/lib/utils"
import type { Icon } from "@/types/icons"
import Image from "next/image"
import Link from "next/link"
import { preload } from "react-dom"
export function IconCard({
name,
data: iconData,
matchedAlias,
}: {
name: string
data: Icon
matchedAlias?: string
}) {
const formatedIconName = formatIconName(name)
return (
<MagicCard className="rounded-md shadow-md">
<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer">
<div className="relative h-16 w-16 mb-2">
<Image
src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`}
alt={`${name} icon`}
fill
className="object-contain p-1 group-hover:scale-110 transition-transform duration-300"
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize group- dark:group-hover:text-primary transition-colors duration-200 font-medium">
{formatedIconName}
</span>
</Link>
</MagicCard>
)
}

View File

@@ -1,14 +1,17 @@
"use client"
import { IconsGrid } from "@/components/icon-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { BASE_URL, REPO_PATH } from "@/constants"
import type { AuthorData, Icon } from "@/types/icons"
import { formatIconName } from "@/lib/utils"
import type { AuthorData, Icon, IconFile } from "@/types/icons"
import confetti from "canvas-confetti"
import { motion } from "framer-motion"
import { Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun, Type } from "lucide-react"
import { ArrowRight, Check, Copy, Download, FileType, Github, Moon, PaletteIcon, Sun } from "lucide-react"
import dynamic from "next/dynamic"
import Image from "next/image"
import Link from "next/link"
import { useCallback, useState } from "react"
@@ -21,305 +24,10 @@ export type IconDetailsProps = {
icon: string
iconData: Icon
authorData: AuthorData
allIcons: IconFile
}
function IconVariant({
format,
iconName,
theme,
isWordmark = false,
iconData,
onCopy,
onDownload,
copiedVariants
}: {
format: string
iconName: string
theme?: "light" | "dark"
isWordmark?: boolean
iconData: Icon
onCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
onDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
}) {
let variantName = '';
if (isWordmark) {
if (theme && iconData.wordmark && typeof iconData.wordmark !== 'string') {
variantName = iconData.wordmark[theme] || `${iconName}_wordmark_${theme}`;
} else if (iconData.wordmark && typeof iconData.wordmark === 'string') {
variantName = iconData.wordmark;
} else {
variantName = `${iconName}_wordmark`;
}
} else {
if (theme && iconData.colors) {
variantName = iconData.colors[theme] || `${iconName}_${theme}`;
} else {
variantName = iconName;
}
}
const imageUrl = `${BASE_URL}/${format}/${variantName}.${format}`;
const githubUrl = `${REPO_PATH}/tree/main/${format}/${variantName}.${format}`;
const variantKey = `${format}-${theme || "default"}${isWordmark ? "-wordmark" : ""}`;
const isCopied = copiedVariants[variantKey] || false;
const btnCopied = copiedVariants[`btn-${variantKey}`] || false;
return (
<TooltipProvider delayDuration={500}>
<MagicCard className="p-0 rounded-md">
<div className="flex flex-col items-center p-4 transition-all">
<Tooltip>
<TooltipTrigger asChild>
<motion.div
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => onCopy(imageUrl, variantKey, e)}
>
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
<motion.div
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
initial={{ opacity: 0 }}
animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
{isCopied && (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: 1,
opacity: 1,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<Check className="w-8 h-8 text-primary" />
</motion.div>
)}
</motion.div>
<Image
src={imageUrl}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
className="object-contain p-4"
/>
</motion.div>
</TooltipTrigger>
<TooltipContent>
<p>Click to copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">{format.toUpperCase()}</p>
<div className="flex gap-2 mt-3 w-full justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => onDownload(e, imageUrl, `${iconName}.${format}`)}
>
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download icon file</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => onCopy(imageUrl, `btn-${variantKey}`, e)}
>
{btnCopied && <Check className="w-4 h-4 text-green-500" />}
{!btnCopied && <Copy className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</MagicCard>
</TooltipProvider>
)
}
function IconVariantsSection({
availableFormats,
icon,
theme,
isWordmark = false,
iconData,
handleCopy,
handleDownload,
copiedVariants,
title,
iconElement
}: {
availableFormats: string[]
icon: string
theme?: "light" | "dark"
isWordmark?: boolean
iconData: Icon
handleCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
handleDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
title: string
iconElement: React.ReactNode
}) {
return (
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
{iconElement}
{title}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme={theme}
isWordmark={isWordmark}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)
}
function WordmarkSection({
iconData,
icon,
availableFormats,
handleCopy,
handleDownload,
copiedVariants
}: {
iconData: Icon
icon: string
availableFormats: string[]
handleCopy: (url: string, variantKey: string, event?: React.MouseEvent) => void
handleDownload: (event: React.MouseEvent, url: string, filename: string) => void
copiedVariants: Record<string, boolean>
}) {
if (!iconData.wordmark) return null;
const isStringWordmark = typeof iconData.wordmark === 'string';
const hasLightDarkVariants = !isStringWordmark && (iconData.wordmark.light || iconData.wordmark.dark);
return (
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<Type className="w-4 h-4 text-green-500" />
Wordmark variants
</h3>
{(isStringWordmark || !hasLightDarkVariants) && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
)}
{hasLightDarkVariants && (
<div className="space-y-6">
{iconData.wordmark.light && (
<div>
<h4 className="text-md font-medium flex items-center gap-2 ml-4 mb-2">
<Sun className="w-3 h-3 text-amber-500" />
Light
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme="light"
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)}
{iconData.wordmark.dark && (
<div>
<h4 className="text-md font-medium flex items-center gap-2 ml-4 mb-2">
<Moon className="w-3 h-3 text-indigo-500" />
Dark
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 p-3 rounded-lg">
{availableFormats.map((format) => (
<IconVariant
key={format}
format={format}
iconName={icon}
theme="dark"
isWordmark={true}
iconData={iconData}
onCopy={handleCopy}
onDownload={handleDownload}
copiedVariants={copiedVariants}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) {
const authorName = authorData.name || authorData.login || ""
const iconColorVariants = iconData.colors
const iconWordmarkVariants = iconData.wordmark
@@ -425,23 +133,147 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
}
}
const renderVariant = (format: string, iconName: string, theme?: "light" | "dark") => {
const variantName = theme && iconColorVariants?.[theme] ? iconColorVariants[theme] : iconName
const imageUrl = `${BASE_URL}/${format}/${variantName}.${format}`
const githubUrl = `${REPO_PATH}/tree/main/${format}/${iconName}.${format}`
const variantKey = `${format}-${theme || "default"}`
const isCopied = copiedVariants[variantKey] || false
return (
<TooltipProvider key={variantKey} delayDuration={500}>
<MagicCard className="p-0 rounded-md">
<div className="flex flex-col items-center p-4 transition-all">
<Tooltip>
<TooltipTrigger asChild>
<motion.div
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => handleCopy(imageUrl, variantKey, e)}
aria-label={`Copy ${format.toUpperCase()} URL for ${iconName}${theme ? ` (${theme} theme)` : ""}`}
>
<div className="absolute inset-0 border-2 border-transparent group-hover:border-primary/20 rounded-xl z-10 transition-colors" />
<motion.div
className="absolute inset-0 bg-primary/10 flex items-center justify-center z-20 rounded-xl"
initial={{ opacity: 0 }}
animate={{ opacity: isCopied ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{
scale: isCopied ? 1 : 0.5,
opacity: isCopied ? 1 : 0,
}}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
>
<Check className="w-8 h-8 text-primary" />
</motion.div>
</motion.div>
<Image
src={imageUrl}
alt={`${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
fill
loading="eager"
className="object-contain p-4"
/>
</motion.div>
</TooltipTrigger>
<TooltipContent>
<p>Click to copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">{format.toUpperCase()}</p>
<div className="flex gap-2 mt-3 w-full justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleDownload(e, imageUrl, `${iconName}.${format}`)}
aria-label={`Download ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
>
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download icon file</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 rounded-lg cursor-pointer"
onClick={(e) => handleCopy(imageUrl, `btn-${variantKey}`, e)}
aria-label={`Copy URL for ${iconName} in ${format} format${theme ? ` (${theme} theme)` : ""}`}
>
{copiedVariants[`btn-${variantKey}`] ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy direct URL to clipboard</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`View ${iconName} ${format} file on GitHub`}
>
<Github className="w-4 h-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>View on GitHub</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</MagicCard>
</TooltipProvider>
)
}
const formatedIconName = formatIconName(icon)
return (
<div className="container mx-auto pt-12 pb-14">
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-1">
<Card className="h-full bg-background/50 border shadow-lg">
<CardHeader className="pb-4">
<div className="flex flex-col items-center">
<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3 ">
<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3">
<Image
src={`${BASE_URL}/${iconData.base}/${icon}.${iconData.base}`}
width={96}
height={96}
alt={icon}
placeholder="empty"
alt={`High quality ${formatedIconName} icon in ${iconData.base.toUpperCase()} format`}
className="w-full h-full object-contain"
/>
</div>
<CardTitle className="text-2xl font-bold capitalize text-center mb-2">{icon}</CardTitle>
<CardTitle className="text-2xl font-bold capitalize text-center mb-2">
<h1>{formatedIconName}</h1>
</CardTitle>
</div>
</CardHeader>
<CardContent>
@@ -450,14 +282,14 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="space-y-2">
<div className="flex items-center gap-2">
<p className="text-sm">
<span className="font-medium">Updated on:</span> {formattedDate}
<span className="font-medium">Updated on:</span> <time dateTime={iconData.update.timestamp}>{formattedDate}</time>
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">By:</p>
<Avatar className="h-5 w-5 border">
<AvatarImage src={authorData.avatar_url} alt={authorName} />
<AvatarImage src={authorData.avatar_url} alt={`${authorName}'s avatar`} />
<AvatarFallback>{authorName ? authorName.slice(0, 2).toUpperCase() : "??"}</AvatarFallback>
</Avatar>
{authorData.html_url && (
@@ -480,7 +312,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{iconData.categories && iconData.categories.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground">Categories</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Categories</h3>
<div className="flex flex-wrap gap-2">
{iconData.categories.map((category) => (
<Link key={category} href={`/icons?category=${encodeURIComponent(category)}`} className="cursor-pointer">
@@ -501,7 +333,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{iconData.aliases && iconData.aliases.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground">Aliases</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Aliases</h3>
<div className="flex flex-wrap gap-2">
{iconData.aliases.map((alias) => (
<Badge
@@ -518,23 +350,20 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
)}
<div>
<h3 className="text-sm font-semibold text-muted-foreground">About this icon</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">About this icon</h3>
<div className="text-xs text-muted-foreground space-y-2">
<p>
Available in{" "}
{availableFormats.length > 1 && (
`${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})`
)}
{availableFormats.length === 1 && (
`${availableFormats[0].toUpperCase()} format`
)}{" "}
{availableFormats.length > 1
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")}) `
: `${availableFormats[0].toUpperCase()} format `}
with a base format of {iconData.base.toUpperCase()}.
{iconData.colors && " Includes both light and dark theme variants for better integration with different UI designs."}
{iconData.wordmark && " Wordmark variants are also available for enhanced branding options."}
</p>
<p>
Use the {icon} icon in your web applications, dashboards, or documentation to enhance visual communication and user
experience.
Perfect for adding to dashboards, app directories, documentation, or anywhere you need the {formatIconName(icon)}{" "}
logo.
</p>
</div>
</div>
@@ -546,7 +375,9 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<div className="lg:col-span-2">
<Card className="h-full bg-background/50 shadow-lg">
<CardHeader>
<CardTitle>Icon variants</CardTitle>
<CardTitle>
<h2>Icon variants</h2>
</CardTitle>
<CardDescription>Click on any icon to copy its URL to your clipboard</CardDescription>
</CardHeader>
<CardContent>
@@ -613,7 +444,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
<CardContent>
<div className="space-y-6">
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Base format</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Base format</h3>
<div className="flex items-center gap-2">
<FileType className="w-4 h-4 text-blue-500" />
<div className="px-3 py-1.5 border border-border rounded-lg text-sm font-medium">{iconData.base.toUpperCase()}</div>
@@ -621,7 +452,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</div>
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Available formats</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Available formats</h3>
<div className="flex flex-wrap gap-2">
{availableFormats.map((format) => (
<div key={format} className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium">
@@ -633,7 +464,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
{iconData.colors && (
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Color variants</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Color variants</h3>
<div className="space-y-2">
{Object.entries(iconData.colors).map(([theme, variant]) => (
<div key={theme} className="flex items-center gap-2">
@@ -669,7 +500,7 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
)}
<div className="">
<h3 className="text-sm font-semibold text-muted-foreground">Source</h3>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Source</h3>
<Button variant="outline" className="w-full" asChild>
<Link href={`${REPO_PATH}/blob/main/meta/${icon}.json`} target="_blank" rel="noopener noreferrer">
<Github className="w-4 h-4 mr-2" />
@@ -683,6 +514,63 @@ export function IconDetails({ icon, iconData, authorData }: IconDetailsProps) {
</Card>
</div>
</div>
</div>
{iconData.categories &&
iconData.categories.length > 0 &&
(() => {
const MAX_RELATED_ICONS = 16
const currentCategories = iconData.categories || []
const relatedIconsWithScore = Object.entries(allIcons)
.map(([name, data]) => {
if (name === icon) return null // Exclude the current icon
const otherCategories = data.categories || []
const commonCategories = currentCategories.filter((cat) => otherCategories.includes(cat))
const score = commonCategories.length
return score > 0 ? { name, data, score } : null
})
.filter((item): item is { name: string; data: Icon; score: number } => item !== null) // Type guard
.sort((a, b) => b.score - a.score) // Sort by score DESC
const topRelatedIcons = relatedIconsWithScore.slice(0, MAX_RELATED_ICONS)
const viewMoreUrl = `/icons?${currentCategories.map((cat) => `category=${encodeURIComponent(cat)}`).join("&")}`
if (topRelatedIcons.length === 0) return null
return (
<section className="container mx-auto mt-12" aria-labelledby="related-icons-title">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>
<h2 id="related-icons-title">Related Icons</h2>
</CardTitle>
<CardDescription>
Other icons from {currentCategories.map((cat) => cat.replace(/-/g, " ")).join(", ")} categories
</CardDescription>
</CardHeader>
<CardContent>
<IconsGrid filteredIcons={topRelatedIcons} matchedAliases={{}} />
{relatedIconsWithScore.length > MAX_RELATED_ICONS && (
<div className="mt-6 text-center">
<Button
asChild
variant="link"
className="text-muted-foreground hover:text-primary transition-colors duration-200 hover:no-underline"
>
<Link href={viewMoreUrl} className="no-underline">
View all related icons
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</section>
)
})()}
</main>
)
}

View File

@@ -0,0 +1,87 @@
import type { Icon } from "@/types/icons"
import { useWindowVirtualizer } from "@tanstack/react-virtual"
import { useEffect, useMemo, useRef, useState } from "react"
import { IconCard } from "./icon-card"
interface IconsGridProps {
filteredIcons: { name: string; data: Icon }[]
matchedAliases: Record<string, string>
}
export function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2">
{filteredIcons.slice(0, 120).map(({ name, data }) => (
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name]} />
))}
</div>
)
}
export function VirtualizedIconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
const listRef = useRef<HTMLDivElement | null>(null)
const [windowWidth, setWindowWidth] = useState(0)
useEffect(() => {
setWindowWidth(window.innerWidth)
const handleResize = () => {
setWindowWidth(window.innerWidth)
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
const columnCount = useMemo(() => {
if (windowWidth >= 1280) return 8 // xl
if (windowWidth >= 1024) return 6 // lg
if (windowWidth >= 768) return 4 // md
if (windowWidth >= 640) return 3 // sm
return 2 // default
}, [windowWidth])
const rowCount = Math.ceil(filteredIcons.length / columnCount)
const rowVirtualizer = useWindowVirtualizer({
count: rowCount,
estimateSize: () => 140,
overscan: 2,
})
return (
<div ref={listRef} className="mt-2">
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const rowStart = virtualRow.index * columnCount
const rowEnd = Math.min(rowStart + columnCount, filteredIcons.length)
const rowIcons = filteredIcons.slice(rowStart, rowEnd)
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
minHeight: 124,
width: "100%",
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
}}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4"
>
{rowIcons.map(({ name, data }) => (
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name]} />
))}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { VirtualizedIconsGrid } from "@/components/icon-grid"
import { IconSubmissionContent } from "@/components/icon-submission-form"
import { MagicCard } from "@/components/magicui/magic-card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
@@ -17,19 +17,15 @@ import {
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { BASE_URL } from "@/constants"
import type { Icon, IconSearchProps } from "@/types/icons"
import { type SortOption, filterAndSortIcons } from "@/lib/utils"
import type { IconSearchProps } from "@/types/icons"
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
import { useTheme } from "next-themes"
import Image from "next/image"
import Link from "next/link"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import posthog from "posthog-js"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
export function IconSearch({ icons }: IconSearchProps) {
const searchParams = useSearchParams()
const initialQuery = searchParams.get("q")
@@ -64,54 +60,6 @@ export function IconSearch({ icons }: IconSearchProps) {
return Array.from(categories).sort()
}, [icons])
// Simple filter function using substring matching
const filterIcons = useCallback(
(query: string, categories: string[], sort: SortOption) => {
// First filter by categories if any are selected
let filtered = icons
if (categories.length > 0) {
filtered = filtered.filter(({ data }) =>
data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())),
)
}
// Then filter by search query
if (query.trim()) {
// Normalization function: lowercase, remove spaces and hyphens
const normalizeString = (str: string) => str.toLowerCase().replace(/[-\s]/g, "")
const normalizedQuery = normalizeString(query)
filtered = filtered.filter(({ name, data }) => {
// Check normalized name
if (normalizeString(name).includes(normalizedQuery)) return true
// Check normalized aliases
if (data.aliases.some((alias) => normalizeString(alias).includes(normalizedQuery))) return true
// Check normalized categories
if (data.categories.some((category) => normalizeString(category).includes(normalizedQuery))) return true
return false
})
}
// Apply sorting
if (sort === "alphabetical-asc") {
return filtered.sort((a, b) => a.name.localeCompare(b.name))
}
if (sort === "alphabetical-desc") {
return filtered.sort((a, b) => b.name.localeCompare(a.name))
}
if (sort === "newest") {
return filtered.sort((a, b) => {
return new Date(b.data.update.timestamp).getTime() - new Date(a.data.update.timestamp).getTime()
})
}
// Default sort (relevance or fallback to alphabetical)
// TODO: Implement actual relevance sorting
return filtered.sort((a, b) => a.name.localeCompare(b.name))
},
[icons],
)
// Find matched aliases for display purposes
const matchedAliases = useMemo(() => {
if (!searchQuery.trim()) return {}
@@ -134,8 +82,13 @@ export function IconSearch({ icons }: IconSearchProps) {
// Use useMemo for filtered icons with debounced query
const filteredIcons = useMemo(() => {
return filterIcons(debouncedQuery, selectedCategories, sortOption)
}, [filterIcons, debouncedQuery, selectedCategories, sortOption])
return filterAndSortIcons({
icons,
query: debouncedQuery,
categories: selectedCategories,
sort: sortOption,
})
}, [icons, debouncedQuery, selectedCategories, sortOption])
const updateResults = useCallback(
(query: string, categories: string[], sort: SortOption) => {
@@ -288,7 +241,7 @@ export function IconSearch({ icons }: IconSearchProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 sm:w-56">
<DropdownMenuLabel className="font-semibold">Categories</DropdownMenuLabel>
<DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-[40vh] overflow-y-auto p-1">
@@ -314,7 +267,7 @@ export function IconSearch({ icons }: IconSearchProps) {
}}
className="cursor-pointer focus: focus:bg-rose-50 dark:focus:bg-rose-950/20"
>
Clear all filters
Clear categories
</DropdownMenuItem>
</>
)}
@@ -335,13 +288,15 @@ export function IconSearch({ icons }: IconSearchProps) {
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
<Search className="h-4 w-4 mr-2" />
Best match
Relevance
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer">
<ArrowDownAZ className="h-4 w-4 mr-2" />A to Z
<ArrowDownAZ className="h-4 w-4 mr-2" />
Name (A-Z)
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer">
<ArrowUpZA className="h-4 w-4 mr-2" />Z to A
<ArrowUpZA className="h-4 w-4 mr-2" />
Name (Z-A)
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
<Calendar className="h-4 w-4 mr-2" />
@@ -355,7 +310,7 @@ export function IconSearch({ icons }: IconSearchProps) {
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
<Button variant="outline" size="sm" onClick={clearFilters} className="flex-1 sm:flex-none cursor-pointer bg-background">
<X className="h-4 w-4 mr-2" />
<span>Clear all</span>
<span>Reset all</span>
</Button>
)}
</div>
@@ -363,7 +318,7 @@ export function IconSearch({ icons }: IconSearchProps) {
{/* Active filter badges */}
{selectedCategories.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="text-sm text-muted-foreground">Filters:</span>
<span className="text-sm text-muted-foreground">Selected:</span>
<div className="flex flex-wrap gap-2">
{selectedCategories.map((category) => (
<Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
@@ -389,7 +344,7 @@ export function IconSearch({ icons }: IconSearchProps) {
}}
className="text-xs h-7 px-2 cursor-pointer"
>
Clear all
Clear
</Button>
</div>
)}
@@ -398,29 +353,37 @@ export function IconSearch({ icons }: IconSearchProps) {
</div>
{filteredIcons.length === 0 ? (
<div className="flex flex-col gap-8 py-12 max-w-2xl mx-auto items-center">
<div className="text-center">
<h2 className="text-3xl sm:text-5xl font-semibold">We don't have this one...yet!</h2>
<div className="flex flex-col gap-8 py-12 px-2 w-full max-w-full sm:max-w-2xl mx-auto items-center overflow-x-hidden">
<div className="text-center w-full">
<h2 className="text-3xl sm:text-5xl font-semibold">Icon not found</h2>
<p className="text-lg text-muted-foreground mt-2">Help us expand our collection</p>
</div>
<div className="flex flex-col gap-4 items-center w-full">
<div id="icon-submission-content" className="w-full">
<IconSubmissionContent />
</div>
<div className="mt-4 flex flex-col sm:flex-row items-center gap-2 justify-center w-full">
<span className="text-sm text-muted-foreground">Can't submit it yourself?</span>
<Button
className="cursor-pointer w-full sm:w-auto truncate whitespace-nowrap"
variant="outline"
size="sm"
onClick={() => {
setIsLazyRequestSubmitted(true)
toast("Request received!", {
description: `We've noted your request for "${searchQuery || "this icon"}". Thanks for your suggestion.`,
})
posthog.capture("lazy icon request", {
query: searchQuery,
categories: selectedCategories,
})
}}
disabled={isLazyRequestSubmitted}
>
Request this icon
</Button>
</div>
</div>
<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>
) : (
<>
@@ -435,57 +398,9 @@ export function IconSearch({ icons }: IconSearchProps) {
</div>
</div>
<IconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} />
<VirtualizedIconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} />
</>
)}
</>
)
}
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-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>}
</>
)
}

View File

@@ -52,12 +52,12 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
className="w-full flex flex-col items-start gap-1 h-auto p-4 text-left cursor-pointer transition-all duration-300"
asChild
>
<div>
<div className="w-full">
<div className="flex w-full items-center justify-between">
<span className="font-medium transition-all duration-300">{template.name}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground transition-all duration-300" />
<span className="font-medium transition-all duration-300 whitespace-normal text-wrap">{template.name}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground transition-all duration-300 flex-shrink-0 ml-2" />
</div>
<span className="text-xs text-muted-foreground">{template.description}</span>
<span className="text-xs text-muted-foreground whitespace-normal text-wrap break-words">{template.description}</span>
</div>
</Button>
</Link>
@@ -66,22 +66,26 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
</div>
)
}
export function IconSubmissionForm() {
export function IconSubmissionForm({ trigger }: { trigger?: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300">
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Contribute new icon
</Button>
</DialogTrigger>
<DialogContent className="md:max-w-4xl backdrop-blur-2xl bg-background">
{trigger ? (
<DialogTrigger asChild>{trigger}</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300 items-center gap-2">
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Submit icon(s)
</Button>
</DialogTrigger>
)}
<DialogContent className="w-[calc(100%-2rem)] max-w-sm md:w-full md:max-w-4xl p-6 backdrop-blur-2xl bg-background flex flex-col gap-4">
<DialogHeader>
<DialogTitle>Contribute a new icon</DialogTitle>
<DialogDescription>Choose a template below to suggest a new icon or improve an existing one.</DialogDescription>
<DialogTitle>Submit an icon</DialogTitle>
<DialogDescription>Select an option below to submit or update an icon.</DialogDescription>
</DialogHeader>
<div className="mt-4">
<div className="overflow-y-auto max-h-[calc(85vh-10rem)] pr-2">
<IconSubmissionContent onClose={() => setOpen(false)} />
</div>
</DialogContent>

View File

@@ -1,33 +0,0 @@
import type { CSSProperties, ComponentPropsWithoutRef, FC } from "react"
import { cn } from "@/lib/utils"
export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> {
shimmerWidth?: number
}
export const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({ children, className, shimmerWidth = 100, ...props }) => {
return (
<span
style={
{
"--shiny-width": `${shimmerWidth}px`,
} as CSSProperties
}
className={cn(
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
// Shine effect
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
// Shine gradient
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
className,
)}
{...props}
>
{children}
</span>
)
}

View File

@@ -2,7 +2,7 @@
import { Marquee } from "@/components/magicui/marquee"
import { BASE_URL } from "@/constants"
import { cn } from "@/lib/utils"
import { cn, formatIconName } from "@/lib/utils"
import type { Icon, IconWithName } from "@/types/icons"
import { format, isToday, isYesterday } from "date-fns"
import { ArrowRight, Clock, ExternalLink } from "lucide-react"
@@ -61,7 +61,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
href="/icons"
className="font-medium inline-flex items-center py-2 px-4 rounded-full border transition-all duration-200 group hover-lift soft-shadow"
>
View complete collection
View all icons
<ArrowRight className="w-4 h-4 ml-1.5 transition-transform duration-200 group-hover:translate-x-1" />
</Link>
</div>
@@ -78,6 +78,7 @@ function RecentIconCard({
name: string
data: Icon
}) {
const formattedIconName = formatIconName(name)
return (
<Link
prefetch={false}
@@ -85,8 +86,9 @@ function RecentIconCard({
className={cn(
"flex flex-col items-center p-3 sm:p-4 rounded-xl border border-border",
"transition-all duration-300 hover:shadow-lg hover:shadow-rose-500/5 relative overflow-hidden hover-lift",
"w-36 mx-2",
"w-36 mx-2 group/item",
)}
aria-label={`View details for ${formattedIconName} icon`}
>
<div className="absolute inset-0 bg-gradient-to-br from-rose-500/5 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300" />
@@ -99,7 +101,7 @@ function RecentIconCard({
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize dark:hover:text-rose-400 transition-colors duration-200 font-medium">
{name.replace(/-/g, " ")}
{formattedIconName}
</span>
<div className="flex items-center justify-center mt-2 w-full">
<span className="text-[10px] sm:text-xs text-muted-foreground flex items-center whitespace-nowrap hover:/70 transition-colors duration-200">
@@ -108,7 +110,7 @@ function RecentIconCard({
</span>
</div>
<div className="absolute top-2 right-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
<div className="absolute top-2 right-2 opacity-0 group-hover/item:opacity-100 transition-opacity duration-200">
<ExternalLink className="w-3 h-3 " />
</div>
</Link>

View File

@@ -33,10 +33,12 @@ function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
contentClassName,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
contentClassName?: string
}) {
return (
<Dialog {...props}>
@@ -44,7 +46,7 @@ function CommandDialog({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<DialogContent className={cn("overflow-hidden p-0", contentClassName)}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>

View File

@@ -1,3 +1,4 @@
import type { IconWithName } from "@/types/icons"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
@@ -5,6 +6,10 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatIconName(name: string) {
return name.replace(/-/g, " ")
}
/**
* Calculate Levenshtein distance between two strings
*/
@@ -80,46 +85,152 @@ export function containsCharsInOrder(str: string, query: string): number {
}
/**
* Advanced fuzzy search with multiple scoring methods
* Returns a score from 0-1, where 1 is a perfect match
* Advanced fuzzy search with composite scoring and bonuses:
* - Bonus for exact, prefix, substring matches (additive)
* - Penalize weak matches
* - Require all query words to be present somewhere for multi-word queries
* - Returns composite score (0-1+)
*/
export function fuzzySearch(text: string, query: string): number {
if (!query) return 1
if (!text) return 0
// Direct inclusion check (highest priority)
const normalizedText = text.toLowerCase()
const normalizedQuery = query.toLowerCase()
if (normalizedText === normalizedQuery) return 1
if (normalizedText.includes(normalizedQuery)) return 0.9
let score = 0
// Check for character sequence matches
// Bonuses for strong matches
if (normalizedText === normalizedQuery) score += 1.0
else if (normalizedText.startsWith(normalizedQuery)) score += 0.85
else if (normalizedText.includes(normalizedQuery)) score += 0.7
// Sequence, similarity, word match
const sequenceScore = containsCharsInOrder(normalizedText, normalizedQuery)
// Calculate string similarity
const similarityScore = calculateStringSimilarity(normalizedText, normalizedQuery)
// Word-by-word matching for multi-word queries
// Multi-word query: require all words to be present somewhere
const textWords = normalizedText.split(/\s+/)
const queryWords = normalizedQuery.split(/\s+/)
let wordMatchCount = 0
for (const queryWord of queryWords) {
for (const textWord of textWords) {
if (
textWord === queryWord ||
textWord.startsWith(queryWord) ||
textWord.includes(queryWord) ||
calculateStringSimilarity(textWord, queryWord) > 0.7 ||
containsCharsInOrder(textWord, queryWord) > 0
calculateStringSimilarity(textWord, queryWord) > 0.8 ||
containsCharsInOrder(textWord, queryWord) > 0.5
) {
wordMatchCount++
break
}
}
}
const allWordsPresent = wordMatchCount === queryWords.length
const wordMatchScore = queryWords.length > 0 ? wordMatchCount / queryWords.length : 0
// Combine scores with weights
return Math.max(sequenceScore * 0.3, similarityScore * 0.3, wordMatchScore * 0.4)
// Composite score
score += sequenceScore * 0.1 + similarityScore * 0.1 + wordMatchScore * 0.6
// Penalize if not all words present in multi-word query
if (queryWords.length > 1 && !allWordsPresent) score *= 0.4
// Penalize very weak matches
if (score < 0.5) score *= 0.3
return score
}
/**
* Filter and sort icons using advanced fuzzy search, categories, and sort options
* - Tunable weights for name, alias, category
* - Penalize if only category matches
* - Require all query words to be present in at least one field
*/
export type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
export function filterAndSortIcons({
icons,
query = "",
categories = [],
sort = "relevance",
limit,
}: {
icons: IconWithName[]
query?: string
categories?: string[]
sort?: SortOption
limit?: number
}): IconWithName[] {
const NAME_WEIGHT = 2.0
const ALIAS_WEIGHT = 1.5
const CATEGORY_WEIGHT = 1.0
const CATEGORY_PENALTY = 0.7 // Penalize if only category matches
let filtered = icons
// Filter by categories if any are selected
if (categories.length > 0) {
filtered = filtered.filter(({ data }) =>
data.categories.some((cat) => categories.some((selectedCat) => cat.toLowerCase() === selectedCat.toLowerCase())),
)
}
if (query.trim()) {
const queryWords = query.toLowerCase().split(/\s+/)
const scored = filtered
.map((icon) => {
const nameScore = fuzzySearch(icon.name, query) * NAME_WEIGHT
const aliasScore =
icon.data.aliases && icon.data.aliases.length > 0
? Math.max(...icon.data.aliases.map((alias) => fuzzySearch(alias, query))) * ALIAS_WEIGHT
: 0
const categoryScore =
icon.data.categories && icon.data.categories.length > 0
? Math.max(...icon.data.categories.map((category) => fuzzySearch(category, query))) * CATEGORY_WEIGHT
: 0
const maxScore = Math.max(nameScore, aliasScore, categoryScore)
// Penalize if only category matches
const onlyCategoryMatch = categoryScore > 0.7 && nameScore < 0.5 && aliasScore < 0.5
const finalScore = onlyCategoryMatch ? maxScore * CATEGORY_PENALTY : maxScore
// Require all query words to be present in at least one field
const allWordsPresent = queryWords.every(
(word) =>
icon.name.toLowerCase().includes(word) ||
icon.data.aliases.some((alias) => alias.toLowerCase().includes(word)) ||
icon.data.categories.some((cat) => cat.toLowerCase().includes(word)),
)
return { icon, score: allWordsPresent ? finalScore : finalScore * 0.4 }
})
.filter((item) => item.score > 0.7)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
return a.icon.name.localeCompare(b.icon.name)
})
filtered = scored.map((item) => item.icon)
}
// Sorting
if (sort === "alphabetical-asc") {
filtered = filtered.slice().sort((a, b) => a.name.localeCompare(b.name))
} else if (sort === "alphabetical-desc") {
filtered = filtered.slice().sort((a, b) => b.name.localeCompare(a.name))
} else if (sort === "newest") {
filtered = filtered.slice().sort((a, b) => {
const aTime = a.data.update?.timestamp ? new Date(a.data.update.timestamp).getTime() : 0
const bTime = b.data.update?.timestamp ? new Date(b.data.update.timestamp).getTime() : 0
return bTime - aTime
})
} // else: relevance (already sorted by score)
if (limit && filtered.length > limit) {
return filtered.slice(0, limit)
}
return filtered
}