mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-10-26 21:19:04 +08:00
Compare commits
5 Commits
feat/struc
...
feat/pagin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f20de48e1a | ||
|
|
57b0e6a1aa | ||
|
|
f32b62009b | ||
|
|
e9fe6d3842 | ||
|
|
eb799c3637 |
916
SEO.md
Normal file
916
SEO.md
Normal 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)
|
||||||
@@ -22,9 +22,6 @@
|
|||||||
"recommended": true,
|
"recommended": true,
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
"noArrayIndexKey": "off"
|
"noArrayIndexKey": "off"
|
||||||
},
|
|
||||||
"security": {
|
|
||||||
"noDangerouslySetInnerHtml": "off"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
# Allow all user agents
|
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
# Sitemap location (adjust if needed)
|
|
||||||
Sitemap: https://dashboardicons.com/sitemap.xml
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { SITE_NAME, SITE_TAGLINE, WEB_URL, getIconDescription } from "@/constants"
|
|
||||||
import { getAllIcons } from "@/lib/api"
|
import { getAllIcons } from "@/lib/api"
|
||||||
import { ImageResponse } from "next/og"
|
import { ImageResponse } from "next/og"
|
||||||
|
|
||||||
@@ -33,9 +32,10 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
let iconData: Buffer | null = null
|
let iconData: Buffer | null = null
|
||||||
try {
|
try {
|
||||||
const iconPath = join(process.cwd(), `../png/${icon}.png`)
|
const iconPath = join(process.cwd(), `../png/${icon}.png`)
|
||||||
|
console.log(`Generating opengraph image for ${icon} (${index + 1} / ${totalIcons}) from path ${iconPath}`)
|
||||||
iconData = await readFile(iconPath)
|
iconData = await readFile(iconPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Icon file might not be found, fallback handled below
|
console.error(`Icon ${icon} was not found locally`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the image data to a data URL or use placeholder
|
// Convert the image data to a data URL or use placeholder
|
||||||
@@ -52,9 +52,9 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
fontFamily: "Inter, system-ui, sans-serif",
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
backgroundColor: "#0f172a", // Dark background (slate-900)
|
backgroundColor: "white",
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)",
|
"radial-gradient(circle at 25px 25px, lightgray 2%, transparent 0%), radial-gradient(circle at 75px 75px, lightgray 2%, transparent 0%)",
|
||||||
backgroundSize: "100px 100px",
|
backgroundSize: "100px 100px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -67,7 +67,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
width: 400,
|
width: 400,
|
||||||
height: 400,
|
height: 400,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: "linear-gradient(135deg, rgba(56, 189, 248, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)",
|
background: "linear-gradient(135deg, rgba(56, 189, 248, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)",
|
||||||
filter: "blur(80px)",
|
filter: "blur(80px)",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
@@ -80,7 +80,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
width: 500,
|
width: 500,
|
||||||
height: 500,
|
height: 500,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: "linear-gradient(135deg, rgba(249, 115, 22, 0.15) 0%, rgba(234, 88, 12, 0.15) 100%)",
|
background: "linear-gradient(135deg, rgba(249, 115, 22, 0.1) 0%, rgba(234, 88, 12, 0.1) 100%)",
|
||||||
filter: "blur(100px)",
|
filter: "blur(100px)",
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
@@ -109,8 +109,8 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
width: 320,
|
width: 320,
|
||||||
height: 320,
|
height: 320,
|
||||||
borderRadius: 32,
|
borderRadius: 32,
|
||||||
background: "#1e293b", // Dark container (slate-800)
|
background: "white",
|
||||||
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1)",
|
boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05)",
|
||||||
padding: 30,
|
padding: 30,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@@ -121,7 +121,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
background: "linear-gradient(145deg, #1e293b 0%, #0f172a 100%)",
|
background: "linear-gradient(145deg, #ffffff 0%, #f8fafc 100%)",
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -134,7 +134,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
position: "relative",
|
position: "relative",
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
filter: "drop-shadow(0 10px 15px rgba(0, 0, 0, 0.3))",
|
filter: "drop-shadow(0 10px 15px rgba(0, 0, 0, 0.1))",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,7 +154,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
fontSize: 64,
|
fontSize: 64,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: "#f8fafc", // Light text for dark background (slate-50)
|
color: "#0f172a",
|
||||||
lineHeight: 1.1,
|
lineHeight: 1.1,
|
||||||
letterSpacing: "-0.02em",
|
letterSpacing: "-0.02em",
|
||||||
}}
|
}}
|
||||||
@@ -167,14 +167,14 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: "#94a3b8", // Muted text (slate-400)
|
color: "#64748b",
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
paddingLeft: 16,
|
paddingLeft: 16,
|
||||||
borderLeft: "4px solid #64748b", // slate-500
|
borderLeft: "4px solid #94a3b8",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{getIconDescription(formattedIconName, totalIcons)}
|
Amongst {totalIcons} other high-quality dashboard icons
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -191,14 +191,14 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#334155", // slate-700
|
backgroundColor: "#f1f5f9",
|
||||||
color: "#e2e8f0", // slate-200
|
color: "#475569",
|
||||||
border: "2px solid #475569", // slate-600
|
border: "2px solid #e2e8f0",
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
padding: "8px 16px",
|
padding: "8px 16px",
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.2)",
|
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{format}
|
{format}
|
||||||
@@ -219,8 +219,8 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
background: "#1e293b", // slate-800
|
background: "#ffffff",
|
||||||
borderTop: "2px solid rgba(255, 255, 255, 0.1)",
|
borderTop: "2px solid rgba(0, 0, 0, 0.05)",
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -229,7 +229,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "#e2e8f0", // slate-200
|
color: "#334155",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 10,
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
@@ -239,11 +239,11 @@ export default async function Image({ params }: { params: { icon: string } }) {
|
|||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
backgroundColor: "#3b82f6", // blue-500
|
backgroundColor: "#3b82f6",
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{WEB_URL.replace("https://", "")}
|
dashboardicons.com
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -1,20 +1,8 @@
|
|||||||
import { IconDetails } from "@/components/icon-details"
|
import { IconDetails } from "@/components/icon-details"
|
||||||
import { StructuredData } from "@/components/structured-data"
|
import { BASE_URL, WEB_URL } from "@/constants"
|
||||||
import {
|
|
||||||
BASE_URL,
|
|
||||||
GITHUB_URL,
|
|
||||||
ICON_DETAIL_KEYWORDS,
|
|
||||||
SITE_NAME,
|
|
||||||
SITE_TAGLINE,
|
|
||||||
TITLE_SEPARATOR,
|
|
||||||
WEB_URL,
|
|
||||||
getIconDescription,
|
|
||||||
getIconSchema,
|
|
||||||
} from "@/constants"
|
|
||||||
import { getAllIcons, getAuthorData } from "@/lib/api"
|
import { getAllIcons, getAuthorData } from "@/lib/api"
|
||||||
import type { Metadata, ResolvingMetadata } from "next"
|
import type { Metadata, ResolvingMetadata } from "next"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import Script from "next/script"
|
|
||||||
|
|
||||||
export const dynamicParams = false
|
export const dynamicParams = false
|
||||||
|
|
||||||
@@ -28,12 +16,12 @@ export async function generateStaticParams() {
|
|||||||
export const dynamic = "force-static"
|
export const dynamic = "force-static"
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
params: { icon: string }
|
params: Promise<{ icon: string }>
|
||||||
searchParams: { [key: string]: string | string[] | undefined }
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params, searchParams }: Props, parent: ResolvingMetadata): Promise<Metadata> {
|
export async function generateMetadata({ params, searchParams }: Props, parent: ResolvingMetadata): Promise<Metadata> {
|
||||||
const { icon } = params
|
const { icon } = await params
|
||||||
const iconsData = await getAllIcons()
|
const iconsData = await getAllIcons()
|
||||||
if (!iconsData[icon]) {
|
if (!iconsData[icon]) {
|
||||||
notFound()
|
notFound()
|
||||||
@@ -43,6 +31,8 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
|||||||
const updateDate = new Date(iconsData[icon].update.timestamp)
|
const updateDate = new Date(iconsData[icon].update.timestamp)
|
||||||
const totalIcons = Object.keys(iconsData).length
|
const totalIcons = Object.keys(iconsData).length
|
||||||
|
|
||||||
|
console.debug(`Generated metadata for ${icon} by ${authorName} (${authorData.html_url}) updated at ${updateDate.toLocaleString()}`)
|
||||||
|
|
||||||
const iconImageUrl = `${BASE_URL}/png/${icon}.png`
|
const iconImageUrl = `${BASE_URL}/png/${icon}.png`
|
||||||
const pageUrl = `${WEB_URL}/icons/${icon}`
|
const pageUrl = `${WEB_URL}/icons/${icon}`
|
||||||
const formattedIconName = icon
|
const formattedIconName = icon
|
||||||
@@ -50,39 +40,41 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
|||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join(" ")
|
.join(" ")
|
||||||
|
|
||||||
const title = `${formattedIconName} Icon ${TITLE_SEPARATOR} ${SITE_NAME}`
|
|
||||||
const fullTitle = `${formattedIconName} Icon ${TITLE_SEPARATOR} ${SITE_NAME} ${TITLE_SEPARATOR} ${SITE_TAGLINE}`
|
|
||||||
const description = getIconDescription(formattedIconName, totalIcons)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||||
description,
|
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: [iconImageUrl],
|
||||||
category: "Icons",
|
keywords: [
|
||||||
keywords: ICON_DETAIL_KEYWORDS(formattedIconName),
|
`${formattedIconName} icon`,
|
||||||
|
`${formattedIconName} icon download`,
|
||||||
|
`${formattedIconName} icon svg`,
|
||||||
|
`${formattedIconName} icon png`,
|
||||||
|
`${formattedIconName} icon webp`,
|
||||||
|
`${icon} icon`,
|
||||||
|
"application icon",
|
||||||
|
"tool icon",
|
||||||
|
"web dashboard",
|
||||||
|
"app directory",
|
||||||
|
],
|
||||||
icons: {
|
icons: {
|
||||||
icon: iconImageUrl,
|
icon: iconImageUrl,
|
||||||
},
|
},
|
||||||
abstract: description,
|
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.`,
|
||||||
robots: {
|
|
||||||
index: true,
|
|
||||||
follow: true,
|
|
||||||
},
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: title,
|
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||||
description,
|
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.`,
|
||||||
type: "article",
|
type: "article",
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
authors: [authorName],
|
authors: [authorName],
|
||||||
publishedTime: updateDate.toISOString(),
|
publishedTime: updateDate.toISOString(),
|
||||||
modifiedTime: updateDate.toISOString(),
|
modifiedTime: updateDate.toISOString(),
|
||||||
section: "Icons",
|
section: "Icons",
|
||||||
tags: [formattedIconName, ...ICON_DETAIL_KEYWORDS(formattedIconName)],
|
tags: [formattedIconName, "dashboard icon", "service icon", "application icon", "tool icon", "web dashboard", "app directory"],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: title,
|
title: `${formattedIconName} Icon | Dashboard Icons`,
|
||||||
description,
|
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],
|
images: [iconImageUrl],
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
@@ -96,8 +88,8 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function IconPage({ params }: { params: { icon: string } }) {
|
export default async function IconPage({ params }: { params: Promise<{ icon: string }> }) {
|
||||||
const { icon } = params
|
const { icon } = await params
|
||||||
const iconsData = await getAllIcons()
|
const iconsData = await getAllIcons()
|
||||||
const originalIconData = iconsData[icon]
|
const originalIconData = iconsData[icon]
|
||||||
|
|
||||||
@@ -106,26 +98,6 @@ export default async function IconPage({ params }: { params: { icon: string } })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authorData = await getAuthorData(originalIconData.update.author.id)
|
const authorData = await getAuthorData(originalIconData.update.author.id)
|
||||||
const updateDate = new Date(originalIconData.update.timestamp)
|
|
||||||
const authorName = authorData.name || authorData.login
|
|
||||||
const formattedIconName = icon
|
|
||||||
.split("-")
|
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
||||||
.join(" ")
|
|
||||||
|
|
||||||
const imageSchema = getIconSchema(
|
return <IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
|
||||||
formattedIconName,
|
|
||||||
icon,
|
|
||||||
authorName,
|
|
||||||
authorData.html_url,
|
|
||||||
updateDate.toISOString(),
|
|
||||||
Object.keys(iconsData).length,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StructuredData data={imageSchema} id="image-schema" />
|
|
||||||
<IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { BASE_URL } from "@/constants"
|
import { BASE_URL } from "@/constants"
|
||||||
import type { Icon, IconSearchProps } from "@/types/icons"
|
import type { Icon, IconSearchProps } from "@/types/icons"
|
||||||
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
|
import { ArrowDownAZ, ArrowUpZA, Calendar, ChevronLeft, ChevronRight, Filter, Search, SortAsc, X } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
@@ -30,21 +31,84 @@ import { toast } from "sonner"
|
|||||||
|
|
||||||
type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
|
type SortOption = "relevance" | "alphabetical-asc" | "alphabetical-desc" | "newest"
|
||||||
|
|
||||||
|
// Get the display rows count based on viewport size
|
||||||
|
function getDefaultRowsPerPage() {
|
||||||
|
if (typeof window === "undefined") return 3 // Default for SSR
|
||||||
|
|
||||||
|
// Calculate based on viewport height and width
|
||||||
|
const vh = window.innerHeight
|
||||||
|
const vw = window.innerWidth
|
||||||
|
|
||||||
|
// Determine number of columns based on viewport width
|
||||||
|
let columns = 2 // Default for small screens (sm)
|
||||||
|
if (vw >= 1280)
|
||||||
|
columns = 8 // xl breakpoint
|
||||||
|
else if (vw >= 1024)
|
||||||
|
columns = 6 // lg breakpoint
|
||||||
|
else if (vw >= 768)
|
||||||
|
columns = 4 // md breakpoint
|
||||||
|
else if (vw >= 640) columns = 3 // sm breakpoint
|
||||||
|
|
||||||
|
// Calculate rows (accounting for pagination UI space)
|
||||||
|
const rowHeight = 130 // Approximate height of each row in pixels
|
||||||
|
const availableHeight = vh * 0.6 // 60% of viewport height
|
||||||
|
|
||||||
|
// Ensure at least 1 row, maximum 5 rows
|
||||||
|
return Math.max(1, Math.min(5, Math.floor(availableHeight / rowHeight)))
|
||||||
|
}
|
||||||
|
|
||||||
export function IconSearch({ icons }: IconSearchProps) {
|
export function IconSearch({ icons }: IconSearchProps) {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const initialQuery = searchParams.get("q")
|
const initialQuery = searchParams.get("q")
|
||||||
const initialCategories = searchParams.getAll("category")
|
const initialCategories = searchParams.getAll("category")
|
||||||
const initialSort = (searchParams.get("sort") as SortOption) || "relevance"
|
const initialSort = (searchParams.get("sort") as SortOption) || "relevance"
|
||||||
|
const initialPage = Number(searchParams.get("page") || "1")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [searchQuery, setSearchQuery] = useState(initialQuery ?? "")
|
const [searchQuery, setSearchQuery] = useState(initialQuery ?? "")
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "")
|
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "")
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? [])
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? [])
|
||||||
const [sortOption, setSortOption] = useState<SortOption>(initialSort)
|
const [sortOption, setSortOption] = useState<SortOption>(initialSort)
|
||||||
|
const [currentPage, setCurrentPage] = useState(initialPage)
|
||||||
|
const [iconsPerPage, setIconsPerPage] = useState(getDefaultRowsPerPage() * 8) // Default cols is 8 for xl screens
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
const [isLazyRequestSubmitted, setIsLazyRequestSubmitted] = useState(false)
|
const [isLazyRequestSubmitted, setIsLazyRequestSubmitted] = useState(false)
|
||||||
|
|
||||||
|
// Add resize observer to update iconsPerPage when window size changes
|
||||||
|
useEffect(() => {
|
||||||
|
const updateIconsPerPage = () => {
|
||||||
|
const rows = getDefaultRowsPerPage()
|
||||||
|
|
||||||
|
// Determine columns based on current viewport
|
||||||
|
const vw = window.innerWidth
|
||||||
|
let columns = 2 // Default for small screens
|
||||||
|
if (vw >= 1280)
|
||||||
|
columns = 8 // xl breakpoint
|
||||||
|
else if (vw >= 1024)
|
||||||
|
columns = 6 // lg breakpoint
|
||||||
|
else if (vw >= 768)
|
||||||
|
columns = 4 // md breakpoint
|
||||||
|
else if (vw >= 640) columns = 3 // sm breakpoint
|
||||||
|
|
||||||
|
setIconsPerPage(rows * columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial setup
|
||||||
|
updateIconsPerPage()
|
||||||
|
|
||||||
|
// Add resize listener
|
||||||
|
window.addEventListener("resize", updateIconsPerPage)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => window.removeEventListener("resize", updateIconsPerPage)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Reset page when search parameters change
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedQuery(searchQuery)
|
setDebouncedQuery(searchQuery)
|
||||||
@@ -138,7 +202,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
}, [filterIcons, debouncedQuery, selectedCategories, sortOption])
|
}, [filterIcons, debouncedQuery, selectedCategories, sortOption])
|
||||||
|
|
||||||
const updateResults = useCallback(
|
const updateResults = useCallback(
|
||||||
(query: string, categories: string[], sort: SortOption) => {
|
(query: string, categories: string[], sort: SortOption, page = 1) => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (query) params.set("q", query)
|
if (query) params.set("q", query)
|
||||||
|
|
||||||
@@ -152,12 +216,32 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
params.set("sort", sort)
|
params.set("sort", sort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add page parameter if not the first page
|
||||||
|
if (page > 1) {
|
||||||
|
params.set("page", page.toString())
|
||||||
|
}
|
||||||
|
|
||||||
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname
|
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname
|
||||||
router.push(newUrl, { scroll: false })
|
router.push(newUrl, { scroll: false })
|
||||||
},
|
},
|
||||||
[pathname, router, initialSort],
|
[pathname, router, initialSort],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Validate currentPage when iconsPerPage or filteredIcons change
|
||||||
|
useEffect(() => {
|
||||||
|
// Calculate new total pages
|
||||||
|
const totalPages = Math.ceil(filteredIcons.length / iconsPerPage)
|
||||||
|
|
||||||
|
// If current page is out of bounds, adjust it
|
||||||
|
if (currentPage > totalPages && totalPages > 0) {
|
||||||
|
// Update current page state
|
||||||
|
setCurrentPage(totalPages)
|
||||||
|
|
||||||
|
// Update URL to reflect the adjusted page
|
||||||
|
updateResults(searchQuery, selectedCategories, sortOption, totalPages)
|
||||||
|
}
|
||||||
|
}, [iconsPerPage, filteredIcons.length, currentPage, searchQuery, selectedCategories, sortOption, updateResults])
|
||||||
|
|
||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
(query: string) => {
|
(query: string) => {
|
||||||
setSearchQuery(query)
|
setSearchQuery(query)
|
||||||
@@ -197,11 +281,20 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
[updateResults, searchQuery, selectedCategories],
|
[updateResults, searchQuery, selectedCategories],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(page: number) => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
updateResults(searchQuery, selectedCategories, sortOption, page)
|
||||||
|
},
|
||||||
|
[updateResults, searchQuery, selectedCategories, sortOption],
|
||||||
|
)
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setSearchQuery("")
|
setSearchQuery("")
|
||||||
setSelectedCategories([])
|
setSelectedCategories([])
|
||||||
setSortOption("relevance")
|
setSortOption("relevance")
|
||||||
updateResults("", [], "relevance")
|
setCurrentPage(1)
|
||||||
|
updateResults("", [], "relevance", 1)
|
||||||
}, [updateResults])
|
}, [updateResults])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -435,7 +528,14 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconsGrid filteredIcons={filteredIcons} matchedAliases={matchedAliases} />
|
<IconsGrid
|
||||||
|
filteredIcons={filteredIcons}
|
||||||
|
matchedAliases={matchedAliases}
|
||||||
|
currentPage={currentPage}
|
||||||
|
iconsPerPage={iconsPerPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
totalIcons={filteredIcons.length}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -445,15 +545,13 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
function IconCard({
|
function IconCard({
|
||||||
name,
|
name,
|
||||||
data: iconData,
|
data: iconData,
|
||||||
matchedAlias,
|
|
||||||
}: {
|
}: {
|
||||||
name: string
|
name: string
|
||||||
data: Icon
|
data: Icon
|
||||||
matchedAlias?: string | null
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<MagicCard className="rounded-md shadow-md">
|
<MagicCard className="rounded-md shadow-md cursor-pointer">
|
||||||
<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer">
|
<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4">
|
||||||
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
|
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
|
||||||
<Image
|
<Image
|
||||||
src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`}
|
src={`${BASE_URL}/${iconData.base}/${name}.${iconData.base}`}
|
||||||
@@ -465,8 +563,6 @@ function IconCard({
|
|||||||
<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">
|
<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, " ")}
|
{name.replace(/-/g, " ")}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{matchedAlias && <span className="text-[10px] text-center truncate w-full mt-1">Alias: {matchedAlias}</span>}
|
|
||||||
</Link>
|
</Link>
|
||||||
</MagicCard>
|
</MagicCard>
|
||||||
)
|
)
|
||||||
@@ -475,17 +571,251 @@ function IconCard({
|
|||||||
interface IconsGridProps {
|
interface IconsGridProps {
|
||||||
filteredIcons: { name: string; data: Icon }[]
|
filteredIcons: { name: string; data: Icon }[]
|
||||||
matchedAliases: Record<string, string>
|
matchedAliases: Record<string, string>
|
||||||
|
currentPage: number
|
||||||
|
iconsPerPage: number
|
||||||
|
onPageChange: (page: number) => void
|
||||||
|
totalIcons: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
|
function IconsGrid({ filteredIcons, matchedAliases, currentPage, iconsPerPage, onPageChange, totalIcons }: IconsGridProps) {
|
||||||
|
// Calculate pagination values
|
||||||
|
const totalPages = Math.ceil(totalIcons / iconsPerPage)
|
||||||
|
const indexOfLastIcon = currentPage * iconsPerPage
|
||||||
|
const indexOfFirstIcon = indexOfLastIcon - iconsPerPage
|
||||||
|
const currentIcons = filteredIcons.slice(indexOfFirstIcon, indexOfLastIcon)
|
||||||
|
|
||||||
|
// Calculate letter ranges for each page
|
||||||
|
const getLetterRange = (pageNum: number) => {
|
||||||
|
if (filteredIcons.length === 0) return ""
|
||||||
|
const start = (pageNum - 1) * iconsPerPage
|
||||||
|
const end = Math.min(start + iconsPerPage - 1, filteredIcons.length - 1)
|
||||||
|
|
||||||
|
if (start >= filteredIcons.length) return ""
|
||||||
|
|
||||||
|
const firstLetter = filteredIcons[start].name.charAt(0).toUpperCase()
|
||||||
|
const lastLetter = filteredIcons[end].name.charAt(0).toUpperCase()
|
||||||
|
|
||||||
|
return firstLetter === lastLetter ? firstLetter : `${firstLetter} - ${lastLetter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current page letter range
|
||||||
|
const currentLetterRange = getLetterRange(currentPage)
|
||||||
|
|
||||||
|
// Handle direct page input
|
||||||
|
const [pageInput, setPageInput] = useState(currentPage.toString())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageInput(currentPage.toString())
|
||||||
|
}, [currentPage])
|
||||||
|
|
||||||
|
const handlePageInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPageInput(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageInputSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const pageNumber = Number.parseInt(pageInput)
|
||||||
|
if (!Number.isNaN(pageNumber) && pageNumber >= 1 && pageNumber <= totalPages) {
|
||||||
|
onPageChange(pageNumber)
|
||||||
|
} else {
|
||||||
|
// Reset to current page if invalid
|
||||||
|
setPageInput(currentPage.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<AnimatePresence mode="wait">
|
||||||
{filteredIcons.slice(0, 120).map(({ name, data }) => (
|
<motion.div
|
||||||
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name] || null} />
|
key={currentPage}
|
||||||
))}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
</div>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
{filteredIcons.length > 120 && <p className="text-sm text-muted-foreground">And {filteredIcons.length - 120} more...</p>}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{currentIcons.map(({ name, data }) => (
|
||||||
|
<IconCard key={name} name={name} data={data} />
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex flex-col gap-4 mt-8">
|
||||||
|
{/* Mobile view: centered content */}
|
||||||
|
<div className="text-sm text-muted-foreground text-center md:text-left md:hidden">
|
||||||
|
Showing {indexOfFirstIcon + 1}-{Math.min(indexOfLastIcon, totalIcons)} of {totalIcons} icons
|
||||||
|
{currentLetterRange && <span className="ml-2 font-medium">({currentLetterRange})</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop view layout */}
|
||||||
|
<div className="hidden md:flex justify-between items-center">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {indexOfFirstIcon + 1}-{Math.min(indexOfLastIcon, totalIcons)} of {totalIcons} icons
|
||||||
|
{currentLetterRange && <span className="ml-2 font-medium">({currentLetterRange})</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Page input and total count */}
|
||||||
|
<form onSubmit={handlePageInputSubmit} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
value={pageInput}
|
||||||
|
onChange={handlePageInputChange}
|
||||||
|
className="w-16 h-8 text-center cursor-text"
|
||||||
|
aria-label="Go to page"
|
||||||
|
/>
|
||||||
|
<span className="text-sm whitespace-nowrap">of {totalPages}</span>
|
||||||
|
<Button type="submit" size="sm" variant="outline" className="h-8 cursor-pointer">
|
||||||
|
Go
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Pagination controls */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 rounded-r-none cursor-pointer"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center overflow-hidden">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
// Show pages around current page
|
||||||
|
let pageNum: number
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate letter range for this page
|
||||||
|
const letterRange = getLetterRange(pageNum)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => onPageChange(pageNum)}
|
||||||
|
variant={pageNum === currentPage ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={`h-8 w-8 p-0 rounded-none relative group cursor-pointer transition-colors duration-200 ${
|
||||||
|
pageNum === currentPage ? "font-medium" : ""
|
||||||
|
}`}
|
||||||
|
aria-label={`Page ${pageNum}`}
|
||||||
|
aria-current={pageNum === currentPage ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
{letterRange && (
|
||||||
|
<span className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-popover text-popover-foreground px-2 py-1 rounded text-xs opacity-0 group-hover:opacity-100 transition-opacity shadow-md whitespace-nowrap">
|
||||||
|
{letterRange}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 rounded-l-none cursor-pointer"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile-only pagination layout - centered */}
|
||||||
|
<div className="flex flex-col items-center gap-4 md:hidden">
|
||||||
|
{/* Mobile pagination controls */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 rounded-r-none cursor-pointer"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center overflow-hidden">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
// Show pages around current page - same logic as desktop
|
||||||
|
let pageNum: number
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => onPageChange(pageNum)}
|
||||||
|
variant={pageNum === currentPage ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className={`h-8 w-8 p-0 rounded-none cursor-pointer ${pageNum === currentPage ? "font-medium" : ""}`}
|
||||||
|
aria-label={`Page ${pageNum}`}
|
||||||
|
aria-current={pageNum === currentPage ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 rounded-l-none cursor-pointer"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile page input */}
|
||||||
|
<form onSubmit={handlePageInputSubmit} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={totalPages}
|
||||||
|
value={pageInput}
|
||||||
|
onChange={handlePageInputChange}
|
||||||
|
className="w-16 h-8 text-center cursor-text"
|
||||||
|
aria-label="Go to page"
|
||||||
|
/>
|
||||||
|
<span className="text-sm whitespace-nowrap">of {totalPages}</span>
|
||||||
|
<Button type="submit" size="sm" variant="outline" className="h-8 cursor-pointer">
|
||||||
|
Go
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
import { StructuredData } from "@/components/structured-data"
|
import { BASE_URL } from "@/constants"
|
||||||
import {
|
|
||||||
BASE_URL,
|
|
||||||
BROWSE_KEYWORDS,
|
|
||||||
DEFAULT_OG_IMAGE,
|
|
||||||
GITHUB_URL,
|
|
||||||
ORGANIZATION_NAME,
|
|
||||||
ORGANIZATION_SCHEMA,
|
|
||||||
SITE_NAME,
|
|
||||||
SITE_TAGLINE,
|
|
||||||
TITLE_SEPARATOR,
|
|
||||||
WEB_URL,
|
|
||||||
getBrowseDescription,
|
|
||||||
} from "@/constants"
|
|
||||||
import { getIconsArray } from "@/lib/api"
|
import { getIconsArray } from "@/lib/api"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { IconSearch } from "./components/icon-search"
|
import { IconSearch } from "./components/icon-search"
|
||||||
@@ -20,28 +7,42 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
const icons = await getIconsArray()
|
const icons = await getIconsArray()
|
||||||
const totalIcons = icons.length
|
const totalIcons = icons.length
|
||||||
|
|
||||||
const title = `Browse Icons ${TITLE_SEPARATOR} ${SITE_NAME}`
|
|
||||||
const description = getBrowseDescription(totalIcons)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title: "Browse Icons | Free Dashboard Icons",
|
||||||
description,
|
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||||
keywords: BROWSE_KEYWORDS,
|
keywords: [
|
||||||
|
"browse icons",
|
||||||
|
"dashboard icons",
|
||||||
|
"icon search",
|
||||||
|
"service icons",
|
||||||
|
"application icons",
|
||||||
|
"tool icons",
|
||||||
|
"web dashboard",
|
||||||
|
"app directory",
|
||||||
|
],
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: title,
|
title: "Browse Icons | Free Dashboard Icons",
|
||||||
description,
|
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||||
type: "website",
|
type: "website",
|
||||||
url: `${WEB_URL}/icons`,
|
url: `${BASE_URL}/icons`,
|
||||||
images: [DEFAULT_OG_IMAGE],
|
images: [
|
||||||
|
{
|
||||||
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Browse Dashboard Icons Collection",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: title,
|
title: "Browse Icons | Free Dashboard Icons",
|
||||||
description,
|
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`,
|
||||||
images: [DEFAULT_OG_IMAGE.url],
|
images: ["/og-image-browse.png"],
|
||||||
},
|
},
|
||||||
alternates: {
|
alternates: {
|
||||||
canonical: `${WEB_URL}/icons`,
|
canonical: `${BASE_URL}/icons`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,40 +51,20 @@ export const dynamic = "force-static"
|
|||||||
|
|
||||||
export default async function IconsPage() {
|
export default async function IconsPage() {
|
||||||
const icons = await getIconsArray()
|
const icons = await getIconsArray()
|
||||||
|
|
||||||
const gallerySchema = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "ImageGallery",
|
|
||||||
name: `${SITE_NAME} - Browse ${icons.length} Icons - ${SITE_TAGLINE}`,
|
|
||||||
description: getBrowseDescription(icons.length),
|
|
||||||
url: `${WEB_URL}/icons`,
|
|
||||||
numberOfItems: icons.length,
|
|
||||||
creator: {
|
|
||||||
"@type": "Organization",
|
|
||||||
name: ORGANIZATION_NAME,
|
|
||||||
url: GITHUB_URL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="isolate overflow-hidden">
|
||||||
<StructuredData data={gallerySchema} id="gallery-schema" />
|
<div className="py-8">
|
||||||
<div className="isolate overflow-hidden">
|
<div className="space-y-4 mb-8 mx-auto max-w-7xl">
|
||||||
<div className="py-8">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="space-y-4 mb-8 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
<div>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<h1 className="text-3xl font-bold">Browse icons</h1>
|
||||||
<div>
|
<p className="text-muted-foreground">Search through our collection of {icons.length} beautiful icons.</p>
|
||||||
<h1 className="text-3xl font-bold">Icons</h1>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Search our collection of {icons.length} icons - {SITE_TAGLINE}.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<IconSearch icons={icons} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<IconSearch icons={icons} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,12 @@ import { PostHogProvider } from "@/components/PostHogProvider"
|
|||||||
import { Footer } from "@/components/footer"
|
import { Footer } from "@/components/footer"
|
||||||
import { HeaderWrapper } from "@/components/header-wrapper"
|
import { HeaderWrapper } from "@/components/header-wrapper"
|
||||||
import { LicenseNotice } from "@/components/license-notice"
|
import { LicenseNotice } from "@/components/license-notice"
|
||||||
import { WebsiteStructuredData } from "@/components/structured-data"
|
|
||||||
import { getTotalIcons } from "@/lib/api"
|
import { getTotalIcons } from "@/lib/api"
|
||||||
import type { Metadata, Viewport } from "next"
|
import type { Metadata, Viewport } from "next"
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
import { Toaster } from "sonner"
|
import { Toaster } from "sonner"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
import {
|
import { BASE_URL, getDescription, WEB_URL, websiteTitle } from "@/constants"
|
||||||
DEFAULT_KEYWORDS,
|
|
||||||
DEFAULT_OG_IMAGE,
|
|
||||||
GITHUB_URL,
|
|
||||||
ORGANIZATION_NAME,
|
|
||||||
ORGANIZATION_SCHEMA,
|
|
||||||
SITE_NAME,
|
|
||||||
SITE_TAGLINE,
|
|
||||||
WEB_URL,
|
|
||||||
getDescription,
|
|
||||||
getWebsiteSchema,
|
|
||||||
websiteFullTitle,
|
|
||||||
websiteTitle,
|
|
||||||
} from "@/constants"
|
|
||||||
import { ThemeProvider } from "./theme-provider"
|
import { ThemeProvider } from "./theme-provider"
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -41,42 +27,45 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const { totalIcons } = await getTotalIcons()
|
const { totalIcons } = await getTotalIcons()
|
||||||
const description = getDescription(totalIcons)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL(WEB_URL),
|
metadataBase: new URL(WEB_URL),
|
||||||
title: {
|
title: websiteTitle,
|
||||||
default: websiteTitle,
|
description: getDescription(totalIcons),
|
||||||
template: `%s | ${websiteTitle}`,
|
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
|
||||||
},
|
|
||||||
description,
|
|
||||||
keywords: DEFAULT_KEYWORDS,
|
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
follow: true,
|
follow: true,
|
||||||
"max-image-preview": "large",
|
|
||||||
"max-snippet": -1,
|
|
||||||
"max-video-preview": -1,
|
|
||||||
googleBot: "index, follow",
|
googleBot: "index, follow",
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
siteName: SITE_NAME,
|
siteName: WEB_URL,
|
||||||
type: "website",
|
title: websiteTitle,
|
||||||
locale: "en_US",
|
url: BASE_URL,
|
||||||
title: websiteFullTitle,
|
description: getDescription(totalIcons),
|
||||||
description,
|
images: [
|
||||||
url: WEB_URL,
|
{
|
||||||
images: [DEFAULT_OG_IMAGE],
|
url: "/og-image.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "Dashboard Icons - Dashboard icons for self hosted services",
|
||||||
|
type: "image/png",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: websiteFullTitle,
|
title: WEB_URL,
|
||||||
description,
|
description: getDescription(totalIcons),
|
||||||
images: [DEFAULT_OG_IMAGE.url],
|
images: ["/og-image.png"],
|
||||||
},
|
},
|
||||||
applicationName: SITE_NAME,
|
applicationName: WEB_URL,
|
||||||
|
alternates: {
|
||||||
|
canonical: BASE_URL,
|
||||||
|
},
|
||||||
|
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
title: SITE_NAME,
|
title: "Dashboard Icons",
|
||||||
statusBarStyle: "default",
|
statusBarStyle: "default",
|
||||||
capable: true,
|
capable: true,
|
||||||
},
|
},
|
||||||
@@ -87,35 +76,16 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
{ url: "/favicon-32x32.png", sizes: "32x32", type: "image/png" },
|
||||||
],
|
],
|
||||||
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" }],
|
||||||
other: [
|
|
||||||
{
|
|
||||||
rel: "mask-icon",
|
|
||||||
url: "/safari-pinned-tab.svg",
|
|
||||||
color: "#000000",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
manifest: "/site.webmanifest",
|
manifest: "/site.webmanifest",
|
||||||
authors: [{ name: ORGANIZATION_NAME, url: GITHUB_URL }],
|
|
||||||
creator: ORGANIZATION_NAME,
|
|
||||||
publisher: ORGANIZATION_NAME,
|
|
||||||
category: "Icons",
|
|
||||||
classification: "Dashboard Design Resources",
|
|
||||||
other: {
|
|
||||||
"revisit-after": "7 days",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
const { totalIcons } = await getTotalIcons()
|
|
||||||
const websiteSchema = getWebsiteSchema(totalIcons)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
|
<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
<WebsiteStructuredData websiteSchema={websiteSchema} organizationSchema={ORGANIZATION_SCHEMA} />
|
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
<HeaderWrapper />
|
<HeaderWrapper />
|
||||||
<main className="flex-grow">{children}</main>
|
<main className="flex-grow">{children}</main>
|
||||||
|
|||||||
@@ -1,58 +1,12 @@
|
|||||||
import { HeroSection } from "@/components/hero"
|
import { HeroSection } from "@/components/hero"
|
||||||
import { RecentlyAddedIcons } from "@/components/recently-added-icons"
|
import { RecentlyAddedIcons } from "@/components/recently-added-icons"
|
||||||
import {
|
import { REPO_NAME } from "@/constants"
|
||||||
BASE_URL,
|
|
||||||
DEFAULT_KEYWORDS,
|
|
||||||
DEFAULT_OG_IMAGE,
|
|
||||||
GITHUB_URL,
|
|
||||||
ORGANIZATION_NAME,
|
|
||||||
ORGANIZATION_SCHEMA,
|
|
||||||
REPO_NAME,
|
|
||||||
SITE_NAME,
|
|
||||||
SITE_TAGLINE,
|
|
||||||
WEB_URL,
|
|
||||||
getHomeDescription,
|
|
||||||
websiteFullTitle,
|
|
||||||
websiteTitle,
|
|
||||||
} from "@/constants"
|
|
||||||
import { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api"
|
import { getRecentlyAddedIcons, getTotalIcons } from "@/lib/api"
|
||||||
import type { Metadata } from "next"
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
|
||||||
const { totalIcons } = await getTotalIcons()
|
|
||||||
const description = getHomeDescription(totalIcons)
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: websiteTitle,
|
|
||||||
description,
|
|
||||||
keywords: DEFAULT_KEYWORDS,
|
|
||||||
robots: {
|
|
||||||
index: true,
|
|
||||||
follow: true,
|
|
||||||
},
|
|
||||||
openGraph: {
|
|
||||||
title: websiteFullTitle,
|
|
||||||
description,
|
|
||||||
type: "website",
|
|
||||||
url: WEB_URL,
|
|
||||||
images: [DEFAULT_OG_IMAGE],
|
|
||||||
},
|
|
||||||
twitter: {
|
|
||||||
title: websiteFullTitle,
|
|
||||||
description,
|
|
||||||
card: "summary_large_image",
|
|
||||||
images: [DEFAULT_OG_IMAGE.url],
|
|
||||||
},
|
|
||||||
alternates: {
|
|
||||||
canonical: WEB_URL,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getGitHubStars() {
|
async function getGitHubStars() {
|
||||||
const response = await fetch(`https://api.github.com/repos/${REPO_NAME}`)
|
const response = await fetch(`https://api.github.com/repos/${REPO_NAME}`)
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// TODO: Consider caching this result or fetching at build time to avoid rate limits.
|
console.log(`GitHub stars: ${data.stargazers_count}`)
|
||||||
return data.stargazers_count
|
return data.stargazers_count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,11 +16,9 @@ export default async function Home() {
|
|||||||
const stars = await getGitHubStars()
|
const stars = await getGitHubStars()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col min-h-screen">
|
||||||
<div className="flex flex-col min-h-screen">
|
<HeroSection totalIcons={totalIcons} stars={stars} />
|
||||||
<HeroSection totalIcons={totalIcons} stars={stars} />
|
<RecentlyAddedIcons icons={recentIcons} />
|
||||||
<RecentlyAddedIcons icons={recentIcons} />
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
3
web/src/app/robots.txt
Normal file
3
web/src/app/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Allow: /
|
||||||
|
Sitemap: https://dashboardicons.com/sitemap.xml
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
type StructuredDataProps = {
|
|
||||||
data: Record<string, unknown>
|
|
||||||
id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StructuredData = ({ data, id }: StructuredDataProps) => {
|
|
||||||
return <script id={id} type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }} />
|
|
||||||
}
|
|
||||||
|
|
||||||
type WebsiteStructuredDataProps = {
|
|
||||||
websiteSchema: Record<string, unknown>
|
|
||||||
organizationSchema: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WebsiteStructuredData = ({ websiteSchema, organizationSchema }: WebsiteStructuredDataProps) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StructuredData data={websiteSchema} id="website-schema" />
|
|
||||||
<StructuredData data={organizationSchema} id="organization-schema" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,126 +4,7 @@ export const METADATA_URL = "https://raw.githubusercontent.com/homarr-labs/dashb
|
|||||||
export const WEB_URL = "https://dashboardicons.com"
|
export const WEB_URL = "https://dashboardicons.com"
|
||||||
export const REPO_NAME = "homarr-labs/dashboard-icons"
|
export const REPO_NAME = "homarr-labs/dashboard-icons"
|
||||||
|
|
||||||
// Site-wide metadata constants
|
|
||||||
export const SITE_NAME = "Dashboard Icons"
|
|
||||||
export const TITLE_SEPARATOR = " | "
|
|
||||||
export const SITE_TAGLINE = "Your definitive source for dashboard icons"
|
|
||||||
export const ORGANIZATION_NAME = "Homarr Labs"
|
|
||||||
|
|
||||||
export const getDescription = (totalIcons: number) =>
|
export const getDescription = (totalIcons: number) =>
|
||||||
`A curated collection of ${totalIcons} free icons for dashboards and app directories. Available in SVG, PNG, and WEBP formats. ${SITE_TAGLINE}.`
|
`A collection of ${totalIcons} curated icons for services, applications and tools, designed specifically for dashboards and app directories.`
|
||||||
|
|
||||||
export const getHomeDescription = (totalIcons: number) =>
|
export const websiteTitle = "Free Dashboard Icons - Download High-Quality UI & App Icons"
|
||||||
`Discover our curated collection of ${totalIcons} icons designed specifically for dashboards and app directories. ${SITE_TAGLINE}.`
|
|
||||||
|
|
||||||
export const getBrowseDescription = (totalIcons: number) =>
|
|
||||||
`Browse, search and download from our collection of ${totalIcons} curated icons. All icons available in SVG, PNG, and WEBP formats. ${SITE_TAGLINE}.`
|
|
||||||
|
|
||||||
export const getIconDescription = (iconName: string, totalIcons: number) =>
|
|
||||||
`Download the ${iconName} icon in SVG, PNG, and WEBP formats. Part of our curated collection of ${totalIcons} free icons for dashboards. ${SITE_TAGLINE}.`
|
|
||||||
|
|
||||||
export const websiteTitle = `${SITE_NAME} ${TITLE_SEPARATOR} Free, Curated Icons for Apps & Services`
|
|
||||||
export const websiteFullTitle = `${SITE_NAME} ${TITLE_SEPARATOR} Free, Curated Icons for Apps & Services ${TITLE_SEPARATOR} ${SITE_TAGLINE}`
|
|
||||||
|
|
||||||
// Various keyword sets for different pages
|
|
||||||
export const DEFAULT_KEYWORDS = [
|
|
||||||
"dashboard icons",
|
|
||||||
"app icons",
|
|
||||||
"service icons",
|
|
||||||
"curated icons",
|
|
||||||
"free icons",
|
|
||||||
"SVG icons",
|
|
||||||
"web dashboard",
|
|
||||||
"app directory",
|
|
||||||
]
|
|
||||||
|
|
||||||
export const BROWSE_KEYWORDS = [
|
|
||||||
"browse icons",
|
|
||||||
"search icons",
|
|
||||||
"download icons",
|
|
||||||
"minimal icons",
|
|
||||||
"dashboard design",
|
|
||||||
"UI icons",
|
|
||||||
...DEFAULT_KEYWORDS,
|
|
||||||
]
|
|
||||||
|
|
||||||
// Add format-specific keywords
|
|
||||||
export const ICON_DETAIL_KEYWORDS = (iconName: string): string[] => [
|
|
||||||
`${iconName} icon`, // e.g., "Homarr icon"
|
|
||||||
`${iconName} logo`, // e.g., "Homarr logo"
|
|
||||||
`${iconName} svg icon`, // e.g., "Homarr svg icon"
|
|
||||||
`${iconName} png icon`, // e.g., "Homarr png icon"
|
|
||||||
`${iconName} webp icon`, // e.g., "Homarr webp icon"
|
|
||||||
`${iconName} download`, // e.g., "Homarr download"
|
|
||||||
`${iconName} dashboard icon`, // e.g., "Homarr dashboard icon"
|
|
||||||
...DEFAULT_KEYWORDS,
|
|
||||||
]
|
|
||||||
|
|
||||||
// Core structured data for the website (JSON-LD)
|
|
||||||
export const getWebsiteSchema = (totalIcons: number) => ({
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebSite",
|
|
||||||
name: SITE_NAME,
|
|
||||||
url: WEB_URL,
|
|
||||||
description: getDescription(totalIcons),
|
|
||||||
potentialAction: {
|
|
||||||
"@type": "SearchAction",
|
|
||||||
target: {
|
|
||||||
"@type": "EntryPoint",
|
|
||||||
urlTemplate: `${WEB_URL}/icons?q={search_term_string}`,
|
|
||||||
},
|
|
||||||
"query-input": "required name=search_term_string",
|
|
||||||
},
|
|
||||||
slogan: SITE_TAGLINE,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Organization schema
|
|
||||||
export const ORGANIZATION_SCHEMA = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Organization",
|
|
||||||
name: ORGANIZATION_NAME,
|
|
||||||
url: `https://github.com/${REPO_NAME}`,
|
|
||||||
logo: `${WEB_URL}/og-image.png`,
|
|
||||||
sameAs: [`https://github.com/${REPO_NAME}`, "https://homarr.dev"],
|
|
||||||
slogan: SITE_TAGLINE,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Social media
|
|
||||||
export const GITHUB_URL = `https://github.com/${REPO_NAME}`
|
|
||||||
|
|
||||||
// Image schemas
|
|
||||||
export const getIconSchema = (
|
|
||||||
iconName: string,
|
|
||||||
iconId: string,
|
|
||||||
authorName: string,
|
|
||||||
authorUrl: string,
|
|
||||||
updateDate: string,
|
|
||||||
totalIcons: number,
|
|
||||||
) => ({
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "ImageObject",
|
|
||||||
name: `${iconName} Icon`,
|
|
||||||
description: getIconDescription(iconName, totalIcons),
|
|
||||||
contentUrl: `${BASE_URL}/png/${iconId}.png`,
|
|
||||||
thumbnailUrl: `${BASE_URL}/png/${iconId}.png`,
|
|
||||||
uploadDate: updateDate,
|
|
||||||
author: {
|
|
||||||
"@type": "Person",
|
|
||||||
name: authorName,
|
|
||||||
url: authorUrl,
|
|
||||||
},
|
|
||||||
encodingFormat: ["image/png", "image/svg+xml", "image/webp"],
|
|
||||||
contentSize: "Variable",
|
|
||||||
representativeOfPage: true,
|
|
||||||
creditText: `Icon contributed by ${authorName} to the ${SITE_NAME} collection by ${ORGANIZATION_NAME}`,
|
|
||||||
embedUrl: `${WEB_URL}/icons/${iconId}`,
|
|
||||||
})
|
|
||||||
|
|
||||||
// OpenGraph defaults
|
|
||||||
export const DEFAULT_OG_IMAGE = {
|
|
||||||
url: "/og-image.png",
|
|
||||||
width: 1200,
|
|
||||||
height: 630,
|
|
||||||
alt: `${SITE_NAME} - ${SITE_TAGLINE}`,
|
|
||||||
type: "image/png",
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user